Skip to content

Commit

Permalink
feat(DAL): support for uuid (#547)
Browse files Browse the repository at this point in the history
This PR adds support for UUIDs to the DAL.

Summary of changes:
- The generation script (`yarn client:generate`) was slightly modified
to add a `/// @zod.string.uuid()` comment to the introspected Prisma
schema. This comment instructs the Zod generator to extend the Zod
schema with a uuid validator.
- The generator is changed to expose the UUID type in the generated
Electric client
- An e2e test was added to test that UUIDs can sync to and from Electric
and that writes with invalid UUIDs are rejected by Zod.
  • Loading branch information
kevin-dp authored Oct 30, 2023
1 parent 318b26d commit 88a5375
Show file tree
Hide file tree
Showing 21 changed files with 1,476 additions and 21 deletions.
6 changes: 6 additions & 0 deletions .changeset/rude-guests-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"electric-sql": patch
"@electric-sql/prisma-generator": patch
---

Adds client-side support for int2 and int4 types.
6 changes: 6 additions & 0 deletions .changeset/twelve-cheetahs-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"electric-sql": patch
"@electric-sql/prisma-generator": patch
---

Add client-side validations for UUIDs.
130 changes: 130 additions & 0 deletions clients/typescript/src/cli/migrations/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ async function _generate(opts: Omit<GeneratorOptions, 'watch'>) {
// Introspect the created DB to update the Prisma schema
await introspectDB(prismaSchema)

// Add custom validators (such as uuid) to the Prisma schema
await addValidators(prismaSchema)

// Modify snake_case table names to PascalCase
await pascalCaseTableNames(prismaSchema)

Expand Down Expand Up @@ -326,6 +329,60 @@ async function introspectDB(prismaSchema: string): Promise<void> {
)
}

/**
* Adds validators to the Prisma schema.
* @param prismaSchema Path to the Prisma schema
*/
async function addValidators(prismaSchema: string): Promise<void> {
const lines = await getFileLines(removeComments(prismaSchema))
const newLines = lines.map(addValidator)
// Write the modified Prisma schema to the file
await fs.writeFile(prismaSchema, newLines.join('\n'))
}

/**
* Adds a validator to the Prisma schema line if needed.
* @param ln A line from the Prisma schema
*/
function addValidator(ln: string): string {
const field = parseFields(ln)[0] // try to parse a field (the line could be something else than a field)

if (field) {
const intValidator = '@zod.number.int().gte(-2147483648).lte(2147483647)'

// Map attributes to validators
const attributeValidatorMapping = new Map([
['@db.Uuid', '@zod.string.uuid()'],
['@db.SmallInt', '@zod.number.int().gte(-32768).lte(32767)'],
['@db.Int', intValidator],
])
const attribute = field.attributes
.map((a) => a.type)
.find((a) => attributeValidatorMapping.has(a))

if (attribute) {
return ln + ' /// ' + attributeValidatorMapping.get(attribute)!
} else {
// No attribute validators,
// check if the field's type requires a validator
const typeValidatorMapping = new Map([
['Int', intValidator],
['Int?', intValidator],
['Int[]', intValidator],
])
const typeValidator = typeValidatorMapping.get(field.type)

if (typeValidator) {
return ln + ' /// ' + typeValidator
} else {
return ln
}
}
} else {
return ln
}
}

async function generateElectricClient(prismaSchema: string): Promise<void> {
await executeShellCommand(
`npx prisma generate --schema="${prismaSchema}"`,
Expand Down Expand Up @@ -426,3 +483,76 @@ function isSnakeCased(name: string): boolean {
function snake2PascalCase(name: string): string {
return name.split('_').map(capitaliseFirstLetter).join('')
}

// The below is duplicated code from the generator
// TODO: move it to a separate helper package
// also move the model parsing to the package
// also move the removing comments function

export type Attribute = {
type: `@${string}`
args: Array<string>
}
export type Field = {
field: string
type: string
attributes: Array<Attribute>
}

/**
* Removes all line comments from a string.
* A line comment is a comment that starts with *exactly* `//`.
* It does not remove comments starting with `///`.
*/
function removeComments(str: string): string {
const commentRegex = /(?<=[^/])\/\/(?=[^/]).*$/g // matches // until end of the line (does not match more than 2 slashes)
return str.replaceAll(commentRegex, '')
}

/**
* Takes the body of a model and returns
* an array of fields defined by the model.
* @param body Body of a model
* @returns Fields defined by the model
*/
function parseFields(body: string): Array<Field> {
// The regex below matches the fields of a model (it assumes there are no comments at the end of the line)
// It uses named captured groups to capture the field name, its type, and optional attributes
// the type can be `type` or `type?` or `type[]`
const fieldRegex =
/^\s*(?<field>\w+)\s+(?<type>[\w]+(\?|(\[]))?)\s*(?<attributes>((@[\w.]+\s*)|(@[\w.]+\(.*\)+\s*))+)?\s*$/gm
const fieldMatches = [...body.matchAll(fieldRegex)]
const fs = fieldMatches.map(
(match) =>
match.groups as { field: string; type: string; attributes?: string }
)

return fs.map((f) => ({
...f,
attributes: parseAttributes(f.attributes ?? ''),
}))
}

/**
* Takes a string of attributes, e.g. `@id @db.Timestamp(2)`,
* and returns an array of attributes, e.g. `['@id', '@db.Timestamp(2)]`.
* @param attributes String of attributes
* @returns Array of attributes.
*/
function parseAttributes(attributes: string): Array<Attribute> {
// Matches each attribute in a string of attributes
// e.g. @id @db.Timestamp(2)
// The optional args capture group matches anything
// but not @or newline because that would be the start of a new attribute
const attributeRegex = /(?<type>@[\w.]+)(?<args>\([^@\n\r]+\))?/g
const matches = [...attributes.matchAll(attributeRegex)]
return matches.map((m) => {
const { type, args } = m.groups! as { type: string; args?: string }
const noParens = args?.substring(1, args.length - 1) // arguments without starting '(' and closing ')'
const parsedArgs = noParens?.split(',')?.map((arg) => arg.trim()) ?? []
return {
type: type as `@${string}`,
args: parsedArgs,
}
})
}
20 changes: 11 additions & 9 deletions clients/typescript/test/client/conversions/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ await tbl.sync()
function setupDB() {
db.exec('DROP TABLE IF EXISTS DataTypes')
db.exec(
"CREATE TABLE DataTypes('id' int PRIMARY KEY, 'date' varchar, 'time' varchar, 'timetz' varchar, 'timestamp' varchar, 'timestamptz' varchar, 'bool' int, 'relatedId' int);"
"CREATE TABLE DataTypes('id' int PRIMARY KEY, 'date' varchar, 'time' varchar, 'timetz' varchar, 'timestamp' varchar, 'timestamptz' varchar, 'bool' int, 'uuid' varchar, 'int2' int2, 'int4' int4, 'relatedId' int);"
)

db.exec('DROP TABLE IF EXISTS Dummy')
Expand Down Expand Up @@ -215,6 +215,9 @@ const dateNulls = {
timestamp: null,
timestamptz: null,
bool: null,
int2: null,
int4: null,
uuid: null,
}

const nulls = {
Expand Down Expand Up @@ -535,13 +538,12 @@ test.serial('deleteMany transforms JS objects to SQLite', async (t) => {

t.is(deleteRes.count, 2)

const fetchRes = await tbl.findMany()

t.deepEqual(fetchRes, [
{
...dateNulls,
...o3,
relatedId: null,
const fetchRes = await tbl.findMany({
select: {
id: true,
timestamp: true,
},
])
})

t.deepEqual(fetchRes, [o3])
})
2 changes: 1 addition & 1 deletion clients/typescript/test/client/conversions/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ await tbl.sync()
function setupDB() {
db.exec('DROP TABLE IF EXISTS DataTypes')
db.exec(
"CREATE TABLE DataTypes('id' int PRIMARY KEY, 'date' varchar, 'time' varchar, 'timetz' varchar, 'timestamp' varchar, 'timestamptz' varchar, 'bool' int, 'relatedId' int);"
"CREATE TABLE DataTypes('id' int PRIMARY KEY, 'date' varchar, 'time' varchar, 'timetz' varchar, 'timestamp' varchar, 'timestamptz' varchar, 'bool' int, 'uuid' varchar, 'int2' int2, 'int4' int4, 'relatedId' int);"
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ exports.Prisma.DataTypesScalarFieldEnum = {
timestamp: 'timestamp',
timestamptz: 'timestamptz',
bool: 'bool',
uuid: 'uuid',
int2: 'int2',
int4: 'int4',
relatedId: 'relatedId'
};

Expand Down
Loading

0 comments on commit 88a5375

Please sign in to comment.