diff --git a/utils/escapeSingleQuoted.js b/utils/escapeSingleQuoted.js new file mode 100644 index 0000000..7e89307 --- /dev/null +++ b/utils/escapeSingleQuoted.js @@ -0,0 +1,11 @@ +/** + * Escapes a string so it can be safely embedded inside a single-quoted + * TypeScript/JavaScript string literal. Backslashes are escaped before quotes + * so that the backslashes added when escaping quotes are not re-escaped. + * e.g. o'connor → o\'connor, a\b → a\\b + * @param {string} value + * @returns {string} + */ +export function escapeSingleQuoted(value) { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} diff --git a/utils/escapeSingleQuoted.test.js b/utils/escapeSingleQuoted.test.js new file mode 100644 index 0000000..689c18e --- /dev/null +++ b/utils/escapeSingleQuoted.test.js @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { escapeSingleQuoted } from './escapeSingleQuoted.js'; + +describe('escapeSingleQuoted', () => { + it('should leave a plain string unchanged', () => { + expect(escapeSingleQuoted('connor')).toBe('connor'); + }); + + it('should escape single quotes', () => { + expect(escapeSingleQuoted("o'connor")).toBe("o\\'connor"); + }); + + it('should escape backslashes', () => { + expect(escapeSingleQuoted('a\\b')).toBe('a\\\\b'); + }); + + it('should escape backslashes before quotes so escapes are not doubled', () => { + // input: a ' b \ c -> a \' b \\ c + expect(escapeSingleQuoted("a'b\\c")).toBe("a\\'b\\\\c"); + }); +}); diff --git a/utils/generateInterface.js b/utils/generateInterface.js index 6f9f768..d3c4241 100644 --- a/utils/generateInterface.js +++ b/utils/generateInterface.js @@ -1,6 +1,8 @@ /** @typedef {import('harperdb').Table} Table */ +import { escapeSingleQuoted } from './escapeSingleQuoted.js'; import { isNullable } from './isNullable.js'; import { mapType } from './mapType.js'; +import { safeKey } from './safeKey.js'; import { singularize } from './singularize.js'; import { toIdentifier } from './toIdentifier.js'; @@ -23,7 +25,7 @@ export function generateInterface(table) { const type = mapType(attribute); const primaryKey = !!attribute.isPrimaryKey; const nullable = !primaryKey && isNullable(attribute); - code += `\t${attribute.name}${nullable ? '?' : ''}: ${type};\n`; + code += `\t${safeKey(attribute.name)}${nullable ? '?' : ''}: ${type};\n`; if (primaryKey) { primaryKeys.push(attribute.name); } @@ -31,7 +33,7 @@ export function generateInterface(table) { code += `}\n\n`; const hasPks = primaryKeys.length > 0; - const pks = hasPks ? primaryKeys.map((pk) => `'${pk}'`).join(' | ') : null; + const pks = hasPks ? primaryKeys.map((pk) => `'${escapeSingleQuoted(pk)}'`).join(' | ') : null; if (hasPks) { code += `export type ${dbPrefix}New${singularRaw} = Omit<${singular}, ${pks}>;\n`; diff --git a/utils/generateInterface.test.js b/utils/generateInterface.test.js index 5e3653e..ae0b074 100644 --- a/utils/generateInterface.test.js +++ b/utils/generateInterface.test.js @@ -93,6 +93,65 @@ describe('generateInterface', () => { expect(result).not.toContain('blog-post'); }); + it('should produce valid identifiers for table names starting with a digit', () => { + const table = { + tableName: '123_New4', + attributes: [ + { name: 'id', type: 'ID', isPrimaryKey: true }, + { name: '__createdtime__', type: 'Any' }, + { name: '__updatedtime__', type: 'Any' }, + ], + }; + const result = generateInterface(table); + expect(result).toContain('export interface _123_New4 {'); + expect(result).toContain("export type New_123_New4 = Omit<_123_New4, 'id'>;"); + expect(result).toContain('export type { _123_New4 as _123_New4Record };'); + expect(result).toContain('export type _123_New4Records = _123_New4[];'); + expect(result).toContain("export type New_123_New4Record = Omit<_123_New4, 'id'>;"); + // the original, invalid `interface 123_New4` must not appear anywhere + expect(result).not.toMatch(/\b123_New4\b/); + }); + + it('should quote attribute names that are not valid identifiers', () => { + const table = { + tableName: 'Things', + attributes: [ + { name: 'id', type: 'ID', isPrimaryKey: true }, + { name: 'first-name', type: 'String', nullable: false }, + { name: '123field', type: 'String', nullable: true }, + { name: 'with space', type: 'String', nullable: false }, + { name: 'normalField', type: 'String', nullable: false }, + ], + }; + const result = generateInterface(table); + expect(result).toContain("'first-name': string;"); + expect(result).toContain("'123field'?: string;"); + expect(result).toContain("'with space': string;"); + // valid identifiers stay unquoted + expect(result).toContain('id: string;'); + expect(result).toContain('normalField: string;'); + }); + + it('should quote a primary key attribute name consistently in the interface and Omit', () => { + const table = { + tableName: 'Things', + attributes: [{ name: 'weird-key', type: 'ID', isPrimaryKey: true }], + }; + const result = generateInterface(table); + expect(result).toContain("'weird-key': string;"); + expect(result).toContain("export type NewThing = Omit;"); + }); + + it('should escape a quote in a primary key name in both the property and the Omit', () => { + const table = { + tableName: 'Things', + attributes: [{ name: "o'connor", type: 'ID', isPrimaryKey: true }], + }; + const result = generateInterface(table); + expect(result).toContain("'o\\'connor': string;"); + expect(result).toContain("export type NewThing = Omit;"); + }); + it('should handle databaseName prefix with dashed table name', () => { const table = { tableName: 'audit-logs', diff --git a/utils/generateJSDoc.js b/utils/generateJSDoc.js index fbdaeb9..e64d4bf 100644 --- a/utils/generateJSDoc.js +++ b/utils/generateJSDoc.js @@ -1,4 +1,5 @@ /** @typedef {import('harperdb').Table} Table */ +import { escapeSingleQuoted } from './escapeSingleQuoted.js'; import { isNullable } from './isNullable.js'; import { mapType } from './mapType.js'; import { singularize } from './singularize.js'; @@ -40,7 +41,7 @@ export function generateJSDoc(table) { code += ` */\n\n`; const hasPks = primaryKeys.length > 0; - const pks = hasPks ? primaryKeys.map((pk) => `'${pk}'`).join(' | ') : null; + const pks = hasPks ? primaryKeys.map((pk) => `'${escapeSingleQuoted(pk)}'`).join(' | ') : null; if (hasPks) { code += `/** @typedef {Omit<${singular}, ${pks}>} ${dbPrefix}New${singularRaw} */\n`; diff --git a/utils/generateJSDoc.test.js b/utils/generateJSDoc.test.js index eed5a68..d1dac67 100644 --- a/utils/generateJSDoc.test.js +++ b/utils/generateJSDoc.test.js @@ -59,6 +59,18 @@ describe('generateJSDoc', () => { expect(code).toContain("@typedef {Omit} NewUserRole"); }); + it('should produce valid identifiers for table names starting with a digit', () => { + const table = { + tableName: '123_New4', + databaseName: 'data', + attributes: [{ name: 'id', type: 'ID', isPrimaryKey: true }], + }; + const code = generateJSDoc(table); + expect(code).toContain('@typedef {Object} _123_New4'); + expect(code).toContain("@typedef {Omit<_123_New4, 'id'>} New_123_New4"); + expect(code).not.toMatch(/\b123_New4\b/); + }); + it('should produce valid identifiers for table names containing dashes', () => { const table = { tableName: 'blog-posts', diff --git a/utils/generateTS.test.js b/utils/generateTS.test.js index d9577c3..a528c4f 100644 --- a/utils/generateTS.test.js +++ b/utils/generateTS.test.js @@ -38,6 +38,24 @@ describe('generateTSFromTables', () => { expect(tsCode).toContain('Generated from HarperDB schemas'); }); + it('should produce a valid type identifier when table name starts with a digit', () => { + const tables = [ + { + tableName: '123_New4', + databaseName: 'data', + attributes: [{ name: 'id', type: 'ID', isPrimaryKey: true }], + }, + ]; + const { tsCode, tables: tablesMeta } = generateTSFromTables(tables); + expect(tsCode).toContain('export interface _123_New4 {'); + // the runtime key is preserved verbatim, but the type name is a valid identifier + expect(tablesMeta[0]).toEqual({ + plural: '123_New4', + singular: '_123_New4', + databaseName: 'data', + }); + }); + it('should produce valid identifiers when table name contains dashes', () => { const tables = [ { diff --git a/utils/generateTablesDTS.js b/utils/generateTablesDTS.js index 0726079..1ca074f 100644 --- a/utils/generateTablesDTS.js +++ b/utils/generateTablesDTS.js @@ -2,21 +2,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { getLogger } from './logger.js'; +import { safeKey } from './safeKey.js'; /** * @param {string} globalTypesPath * @param {string} schemaTypesPath * @param {TableMeta[]} tables */ -/** - * Wraps a property name in quotes if it contains characters that are not - * valid in an unquoted TypeScript identifier (anything other than word chars). - * @param {string} name - */ -function safeKey(name) { - return /[^\w]/.test(name) ? `'${name}'` : name; -} - export function generateTablesDTS(globalTypesPath, schemaTypesPath, tables) { let content = `/** Generated from your schema files diff --git a/utils/generateTablesDTS.test.js b/utils/generateTablesDTS.test.js new file mode 100644 index 0000000..1ef2722 --- /dev/null +++ b/utils/generateTablesDTS.test.js @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { generateTablesDTS } from './generateTablesDTS.js'; + +describe('generateTablesDTS', () => { + /** @type {string} */ + let tmpDir; + /** @type {string} */ + let globalTypesPath; + /** @type {string} */ + let schemaTypesPath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-dts-')); + globalTypesPath = path.join(tmpDir, 'global.d.ts'); + schemaTypesPath = path.join(tmpDir, 'schemas', 'types.ts'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** @param {import('./tableMeta.js').TableMeta[]} tables */ + function generate(tables) { + generateTablesDTS(globalTypesPath, schemaTypesPath, tables); + return fs.readFileSync(globalTypesPath, 'utf8'); + } + + it('should import and reference the singular type for each table', () => { + const content = generate([{ plural: 'Users', singular: 'User', databaseName: 'data' }]); + expect(content).toContain('import type { User } from'); + expect(content).toContain('Users: { new(...args: any[]): Table };'); + }); + + it('should quote a runtime key that starts with a digit', () => { + const content = generate([{ plural: '123_New4', singular: '_123_New4', databaseName: 'data' }]); + // the runtime key must be quoted, and the type reference must be a valid identifier + expect(content).toContain("'123_New4': { new(...args: any[]): Table<_123_New4> };"); + expect(content).toContain('import type { _123_New4 } from'); + // an unquoted leading-digit key would be a TypeScript syntax error + expect(content).not.toMatch(/\n\t+123_New4:/); + }); + + it('should quote keys with non-word characters but leave valid ones unquoted', () => { + const content = generate([ + { plural: 'Users', singular: 'User', databaseName: 'data' }, + { plural: 'audit-logs', singular: 'auditLog', databaseName: 'data' }, + ]); + expect(content).toContain('Users: {'); + expect(content).toContain("'audit-logs': {"); + }); + + it('should quote a database name that starts with a digit', () => { + const content = generate([ + { plural: '9lives_Cats', singular: '_9lives_Cat', databaseName: '9lives' }, + ]); + expect(content).toContain("'9lives': {"); + }); +}); diff --git a/utils/safeKey.js b/utils/safeKey.js new file mode 100644 index 0000000..2de3334 --- /dev/null +++ b/utils/safeKey.js @@ -0,0 +1,18 @@ +import { escapeSingleQuoted } from './escapeSingleQuoted.js'; + +/** + * Wraps a property name in quotes unless it is already a valid unquoted + * TypeScript identifier. This covers names containing characters that are not + * valid in an identifier (e.g. dashes or dots) as well as names that start with + * a digit (e.g. "123_New4"). Valid identifiers are returned unchanged. Quotes + * and backslashes within the name are escaped so the result is always valid. + * + * Use this for object keys and interface property names, which may be quoted. + * It is not suitable for type names (interfaces, type aliases), which must be + * real identifiers — use {@link toIdentifier} for those. + * @param {string} name + * @returns {string} + */ +export function safeKey(name) { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${escapeSingleQuoted(name)}'`; +} diff --git a/utils/safeKey.test.js b/utils/safeKey.test.js new file mode 100644 index 0000000..f8e1386 --- /dev/null +++ b/utils/safeKey.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { safeKey } from './safeKey.js'; + +describe('safeKey', () => { + it('should leave valid identifiers unquoted', () => { + expect(safeKey('id')).toBe('id'); + expect(safeKey('userName')).toBe('userName'); + expect(safeKey('__createdtime__')).toBe('__createdtime__'); + expect(safeKey('$ref')).toBe('$ref'); + }); + + it('should quote names containing characters invalid in an identifier', () => { + expect(safeKey('first-name')).toBe("'first-name'"); + expect(safeKey('with space')).toBe("'with space'"); + expect(safeKey('a.b')).toBe("'a.b'"); + }); + + it('should quote names that start with a digit', () => { + expect(safeKey('123field')).toBe("'123field'"); + expect(safeKey('123_New4')).toBe("'123_New4'"); + }); + + it('should escape single quotes and backslashes within a quoted name', () => { + expect(safeKey("o'connor")).toBe("'o\\'connor'"); + expect(safeKey('a\\b')).toBe("'a\\\\b'"); + }); +}); diff --git a/utils/toIdentifier.js b/utils/toIdentifier.js index 81f4498..88e82bc 100644 --- a/utils/toIdentifier.js +++ b/utils/toIdentifier.js @@ -1,11 +1,25 @@ /** - * Converts a name that may contain dashes into a valid TypeScript identifier - * by converting kebab-case to camelCase. - * e.g. "blog-posts" → "blogPosts", "my-table-name" → "myTableName" - * Names without dashes are returned unchanged. + * Converts an arbitrary table name into a valid TypeScript identifier. + * + * - kebab-case is converted to camelCase: "blog-posts" → "blogPosts" + * - any remaining characters that are not valid in an identifier are replaced + * with underscores: "my.table name" → "my_table_name" + * - a leading digit is prefixed with an underscore, since an identifier cannot + * start with a number: "123_New4" → "_123_New4" + * + * Names that are already valid identifiers are returned unchanged. * @param {string} name * @returns {string} */ export function toIdentifier(name) { - return name.replace(/-([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()); + let identifier = name + // kebab-case → camelCase, e.g. "blog-posts" → "blogPosts" + .replace(/-([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()) + // replace any remaining characters that are invalid in an identifier + .replace(/[^a-zA-Z0-9_$]/g, '_'); + // an identifier cannot start with a digit + if (/^[0-9]/.test(identifier)) { + identifier = `_${identifier}`; + } + return identifier; } diff --git a/utils/toIdentifier.test.js b/utils/toIdentifier.test.js new file mode 100644 index 0000000..0972198 --- /dev/null +++ b/utils/toIdentifier.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { toIdentifier } from './toIdentifier.js'; + +describe('toIdentifier', () => { + it('should leave a valid identifier unchanged', () => { + expect(toIdentifier('Users')).toBe('Users'); + expect(toIdentifier('blog_Posts')).toBe('blog_Posts'); + expect(toIdentifier('$special')).toBe('$special'); + }); + + it('should convert kebab-case to camelCase', () => { + expect(toIdentifier('blog-posts')).toBe('blogPosts'); + expect(toIdentifier('my-table-name')).toBe('myTableName'); + }); + + it('should prefix a leading digit with an underscore', () => { + expect(toIdentifier('123_New4')).toBe('_123_New4'); + expect(toIdentifier('4chan')).toBe('_4chan'); + }); + + it('should replace characters that are invalid in an identifier', () => { + expect(toIdentifier('my.table')).toBe('my_table'); + expect(toIdentifier('table name')).toBe('table_name'); + expect(toIdentifier('weird@name!')).toBe('weird_name_'); + }); + + it('should always produce a valid identifier', () => { + const names = ['123_New4', 'blog-posts', 'my.table', 'table name', '99 bottles']; + for (const name of names) { + expect(toIdentifier(name)).toMatch(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/); + } + }); +});