Skip to content
Open
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
11 changes: 11 additions & 0 deletions utils/escapeSingleQuoted.js
Original file line number Diff line number Diff line change
@@ -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, "\\'");
}
21 changes: 21 additions & 0 deletions utils/escapeSingleQuoted.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
6 changes: 4 additions & 2 deletions utils/generateInterface.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -23,15 +25,15 @@ 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);
}
}
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`;
Expand Down
59 changes: 59 additions & 0 deletions utils/generateInterface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Thing, 'weird-key'>;");
});

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<Thing, 'o\\'connor'>;");
});

it('should handle databaseName prefix with dashed table name', () => {
const table = {
tableName: 'audit-logs',
Expand Down
3 changes: 2 additions & 1 deletion utils/generateJSDoc.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`;
Expand Down
12 changes: 12 additions & 0 deletions utils/generateJSDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ describe('generateJSDoc', () => {
expect(code).toContain("@typedef {Omit<UserRole, 'userId' | 'roleId'>} 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',
Expand Down
18 changes: 18 additions & 0 deletions utils/generateTS.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
10 changes: 1 addition & 9 deletions utils/generateTablesDTS.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions utils/generateTablesDTS.test.js
Original file line number Diff line number Diff line change
@@ -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<User> };');
});

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': {");
});
});
18 changes: 18 additions & 0 deletions utils/safeKey.js
Original file line number Diff line number Diff line change
@@ -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)}'`;
}
27 changes: 27 additions & 0 deletions utils/safeKey.test.js
Original file line number Diff line number Diff line change
@@ -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'");
});
Comment thread
dawsontoth marked this conversation as resolved.

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'");
});
});
24 changes: 19 additions & 5 deletions utils/toIdentifier.js
Original file line number Diff line number Diff line change
@@ -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}`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That'll work!

}
return identifier;
}
33 changes: 33 additions & 0 deletions utils/toIdentifier.test.js
Original file line number Diff line number Diff line change
@@ -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_$]*$/);
}
});
});