Skip to content

Commit

Permalink
Merge pull request #4 from timescale/feat/utils-sql-escape
Browse files Browse the repository at this point in the history
feat: add basic escaping to literals and identifiers
  • Loading branch information
danstarns authored Jan 18, 2025
2 parents 1dd3088 + 0119fa4 commit ac3db69
Show file tree
Hide file tree
Showing 18 changed files with 391 additions and 26 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ If you are looking to setup this project locally, you can follow the instruction

- [@timescaledb/core](./packages/core/README.md)
- [@timescaledb/schemas](./packages/schemas/README.md)
- [@timescaledb/utils](./packages/utils/README.md)

## Examples

Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"test": "jest --runInBand"
},
"dependencies": {
"@timescaledb/schemas": "workspace:^"
"@timescaledb/schemas": "workspace:^",
"@timescaledb/utils": "workspace:^"
},
"devDependencies": {}
}
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export enum HypertableErrors {
NAME_REQUIRED = 'Hypertable name is required',
OPTIONS_REQUIRED = 'Hypertable options are required',
INVALID_OPTIONS = 'Invalid hypertable options',
INVALID_NAME = 'Invalid hypertable name',
}

export enum ExtensionErrors {
Expand Down
39 changes: 26 additions & 13 deletions packages/core/src/hypertable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CreateHypertableOptions, CreateHypertableOptionsSchema } from '@timescaledb/schemas';
import { HypertableErrors } from './errors';
import { escapeIdentifier, escapeLiteral, validateIdentifier } from '@timescaledb/utils';

class HypertableUpBuilder {
private options: CreateHypertableOptions;
Expand All @@ -12,22 +13,26 @@ class HypertableUpBuilder {
}

public build(): string {
this.statements.push(`SELECT create_hypertable('${this.name}', by_range('${this.options.by_range.column_name}'));`);
const tableName = escapeIdentifier(this.name);

this.statements.push(
`SELECT create_hypertable(${escapeLiteral(this.name)}, by_range(${escapeLiteral(this.options.by_range.column_name)}));`,
);

if (this.options.compression?.compress) {
const orderBy = this.options.compression.compress_orderby;
const segmentBy = this.options.compression.compress_segmentby;
const orderBy = escapeIdentifier(this.options.compression.compress_orderby);
const segmentBy = escapeIdentifier(this.options.compression.compress_segmentby);

const alter = `ALTER TABLE ${this.name} SET (
timescaledb.compress,
timescaledb.compress_orderby = '${orderBy}',
timescaledb.compress_segmentby = '${segmentBy}'
const alter = `ALTER TABLE ${tableName} SET (
timescaledb.compress,
timescaledb.compress_orderby = ${orderBy},
timescaledb.compress_segmentby = ${segmentBy}
);`;
this.statements.push(alter);

if (this.options.compression.policy) {
const scheduleInterval = this.options.compression.policy.schedule_interval;
const policy = `SELECT add_compression_policy('${this.name}', INTERVAL '${scheduleInterval}');`;
const interval = escapeLiteral(this.options.compression.policy.schedule_interval);
const policy = `SELECT add_compression_policy(${escapeLiteral(this.name)}, INTERVAL ${interval});`;
this.statements.push(policy);
}
}
Expand All @@ -47,15 +52,17 @@ class HypertableDownBuilder {
}

public build(): string {
const tableName = escapeIdentifier(this.name);

if (this.options.compression?.compress) {
this.statements.push(`ALTER TABLE ${this.name} SET (timescaledb.compress = false);`);
this.statements.push(`ALTER TABLE ${tableName} SET (timescaledb.compress = false);`);
}

if (this.options.compression?.policy) {
this.statements.push(`SELECT remove_compression_policy('${this.name}');`);
this.statements.push(`SELECT remove_compression_policy(${escapeLiteral(this.name)});`);
}

this.statements.push(`SELECT drop_chunks('${this.name}', NOW());`);
this.statements.push(`SELECT drop_chunks(${escapeLiteral(this.name)}, NOW());`);

return this.statements.join('\n');
}
Expand All @@ -69,7 +76,13 @@ export class Hypertable {
if (!name) {
throw new Error(HypertableErrors.NAME_REQUIRED);
}
this.name = name;

try {
validateIdentifier(name, true);
this.name = name;
} catch (error) {
throw new Error(HypertableErrors.INVALID_NAME + ' ' + (error as Error).message);
}

if (!options) {
throw new Error(HypertableErrors.OPTIONS_REQUIRED);
Expand Down
41 changes: 31 additions & 10 deletions packages/core/tests/__snapshots__/hypertable.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Hypertable SQL Escaping should properly escape column names with special characters 1`] = `"SELECT create_hypertable('my_table', by_range('time-stamp"field'));"`;

exports[`Hypertable SQL Escaping should properly escape compression fields with special characters 1`] = `
"SELECT create_hypertable('my_table', by_range('time'));
ALTER TABLE "my_table" SET (
timescaledb.compress,
timescaledb.compress_orderby = "timestamp""field",
timescaledb.compress_segmentby = "user-agent""field"
);"
`;

exports[`Hypertable SQL Escaping should properly escape interval values with special characters 1`] = `
"SELECT create_hypertable('my_table', by_range('time'));
ALTER TABLE "my_table" SET (
timescaledb.compress,
timescaledb.compress_orderby = "time",
timescaledb.compress_segmentby = "user_agent"
);
SELECT add_compression_policy('my_table', INTERVAL '7 days''--injection');"
`;

exports[`Hypertable down should drop a hypertable 1`] = `"SELECT drop_chunks('my_table', NOW());"`;

exports[`Hypertable down should drop a hypertable with compression 1`] = `
"ALTER TABLE my_table SET (timescaledb.compress = false);
"ALTER TABLE "my_table" SET (timescaledb.compress = false);
SELECT drop_chunks('my_table', NOW());"
`;

exports[`Hypertable down should drop a hypertable with compression policy 1`] = `
"ALTER TABLE my_table SET (timescaledb.compress = false);
"ALTER TABLE "my_table" SET (timescaledb.compress = false);
SELECT remove_compression_policy('my_table');
SELECT drop_chunks('my_table', NOW());"
`;
Expand All @@ -17,19 +38,19 @@ exports[`Hypertable up should create and build a hypertable 1`] = `"SELECT creat

exports[`Hypertable up should create and build a hypertable with compression 1`] = `
"SELECT create_hypertable('my_table', by_range('time'));
ALTER TABLE my_table SET (
timescaledb.compress,
timescaledb.compress_orderby = 'time',
timescaledb.compress_segmentby = 'user_agent'
ALTER TABLE "my_table" SET (
timescaledb.compress,
timescaledb.compress_orderby = "time",
timescaledb.compress_segmentby = "user_agent"
);"
`;

exports[`Hypertable up should create and build a hypertable with compression policy 1`] = `
"SELECT create_hypertable('my_table', by_range('time'));
ALTER TABLE my_table SET (
timescaledb.compress,
timescaledb.compress_orderby = 'time',
timescaledb.compress_segmentby = 'user_agent'
ALTER TABLE "my_table" SET (
timescaledb.compress,
timescaledb.compress_orderby = "time",
timescaledb.compress_segmentby = "user_agent"
);
SELECT add_compression_policy('my_table', INTERVAL '1d');"
`;
79 changes: 79 additions & 0 deletions packages/core/tests/hypertable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ describe('Hypertable', () => {
}).toThrow(HypertableErrors.INVALID_OPTIONS);
});

describe('should validate table names correctly', () => {
// Invalid names
const invalidNames = [
'2invalid',
'invalid-name',
'invalid.name.table',
'invalid$name',
'some-2rand}}(*&^name',
'_invalidstart',
];

const validNames = ['valid_table_name', 'schema1.table1'];

it.each(invalidNames)('should fail when creating a hypertable with invalid name: %s', (name) => {
expect(() =>
TimescaleDB.createHypertable(name, {
by_range: { column_name: 'time' },
}),
).toThrow(HypertableErrors.INVALID_NAME);
});

it.each(validNames)('should create a hypertable with valid name: %s', (name) => {
expect(() =>
TimescaleDB.createHypertable(name, {
by_range: { column_name: 'time' },
}),
).not.toThrow();
});
});

describe('up', () => {
it('should create and build a hypertable', () => {
const options: CreateHypertableOptions = {
Expand Down Expand Up @@ -125,4 +155,53 @@ describe('Hypertable', () => {
expect(sql).toMatchSnapshot();
});
});

describe('SQL Escaping', () => {
it('should properly escape column names with special characters', () => {
const options: CreateHypertableOptions = {
by_range: {
column_name: 'time-stamp"field',
},
};

const sql = TimescaleDB.createHypertable('my_table', options).up().build();

expect(sql).toMatchSnapshot();
});

it('should properly escape compression fields with special characters', () => {
const options: CreateHypertableOptions = {
by_range: {
column_name: 'time',
},
compression: {
compress: true,
compress_orderby: 'timestamp"field',
compress_segmentby: 'user-agent"field',
},
};

const sql = TimescaleDB.createHypertable('my_table', options).up().build();
expect(sql).toMatchSnapshot();
});

it('should properly escape interval values with special characters', () => {
const options: CreateHypertableOptions = {
by_range: {
column_name: 'time',
},
compression: {
compress: true,
compress_orderby: 'time',
compress_segmentby: 'user_agent',
policy: {
schedule_interval: "7 days'--injection",
},
},
};

const sql = TimescaleDB.createHypertable('my_table', options).up().build();
expect(sql).toMatchSnapshot();
});
});
});
3 changes: 2 additions & 1 deletion packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@timescaledb/schemas": ["../schemas/dist"]
"@timescaledb/schemas": ["../schemas/dist"],
"@timescaledb/utils": ["../utils/dist"]
}
},
"include": ["src"],
Expand Down
6 changes: 5 additions & 1 deletion packages/core/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"outDir": "./dist-test"
"outDir": "./dist-test",
"paths": {
"@timescaledb/schemas": ["../schemas/dist"],
"@timescaledb/utils": ["../utils/dist"]
}
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules"]
Expand Down
3 changes: 3 additions & 0 deletions packages/utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @timescaledb/utils

This package contains utilities like formatting and escaping sql strings plus other helper functions.
3 changes: 3 additions & 0 deletions packages/utils/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};
20 changes: 20 additions & 0 deletions packages/utils/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.test.json',
},
],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testMatch: ['**/tests/**/*.test.ts', '**/tests/**/*-tests.ts'],
verbose: true,
testTimeout: 10000,
clearMocks: true,
restoreMocks: true,
};
21 changes: 21 additions & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@timescaledb/utils",
"version": "0.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "jest --runInBand"
},
"dependencies": {}
}
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './sql';
Loading

0 comments on commit ac3db69

Please sign in to comment.