Skip to content

Commit

Permalink
feat(tables): ensure constraint names dont exceed postgres name len l…
Browse files Browse the repository at this point in the history
…imit
  • Loading branch information
uladkasach committed Jun 9, 2024
1 parent 333df3d commit 45cc443
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 12 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"foodsource",
"LONGTEXT",
"MEDIUMINT",
"MEDIUMTEXT",
Expand All @@ -22,4 +23,4 @@
}
],
"typescript.tsdk": "node_modules/typescript/lib"
}
}
19 changes: 19 additions & 0 deletions src/__nonpublished_modules__/hash-fns/toHashShake256Sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import crypto from 'crypto';

/**
* hashes a string w/ shake256, which allows for custom length hashes
*
* usecases
* - custom sized hashing, when you want to control length at the cost of collision-probability
*
* note
* - sync version is only available in node env
*
* ref
* - https://stackoverflow.com/a/67073856/3068233
*/
export const toHashShake256Sync = (data: string, length: 2 | 4 | 8 | 16 | 32) =>
crypto
.createHash('shake256', { outputLength: length })
.update(data)
.digest('hex');
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ describe('generateAndRecordEntitySchema', () => {
);
expect(viewCurrentSql).toContain('CREATE OR REPLACE VIEW');
});
it('should record all resources for an entity with array properties that have the same prefix', async () => {
it('should record all resources for an entity with array properties that has a full match prefix', async () => {
const habitat = new Literal({
name: 'sea_turtle_habitat',
properties: {
Expand Down Expand Up @@ -249,4 +249,47 @@ describe('generateAndRecordEntitySchema', () => {
'CREATE OR REPLACE FUNCTION',
);
});

it('should record all resources for an entity with array properties that share a partial prefix', async () => {
const foodsource = new Literal({
name: 'sea_turtle_foodsource',
properties: {
name: prop.VARCHAR(255),
},
});
const habitat = new Entity({
name: 'sea_turtle_habitat',
properties: {
name: prop.VARCHAR(255),
foodsource_ids: prop.ARRAY_OF(prop.REFERENCES(foodsource)),
},
unique: ['name'],
});
await generateAndRecordEntitySchema({
targetDirPath,
entity: habitat,
});

// check static table was created
const tableStaticSql = await readFile(
`${targetDirPath}/tables/${habitat.name}.sql`,
'utf8',
);
expect(tableStaticSql).toContain('CREATE TABLE');

// check that the mapping table was created
const mappingTableOne = await readFile(
`${targetDirPath}/tables/${habitat.name}_to_foodsource.sql`, // !: the shared suffix is not included
'utf8',
);
expect(mappingTableOne).toContain('CREATE TABLE');

// check that the upsert function was created
const upsertSql = await readFile(
`${targetDirPath}/functions/upsert_${habitat.name}.sql`,
'utf8',
);
expect(upsertSql).toContain('CREATE OR REPLACE FUNCTION');
expect(upsertSql).toContain('_to_foodsource');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`generateTableConstraint should safely define the constraint names for constraints that would otherwise have names longer than 64 chars 1`] = `
"CREATE TABLE async_task_predict_train_station_congestion_per_movement_event (
__COLUMN__,
__COLUMN__,
CONSTRAINT async_task_predict_train_station_congestion_per_mov_50cc24a7_pk PRIMARY KEY (id),
CONSTRAINT async_task_predict_train_station_congestion_per_mo_50cc24a7_ux1 UNIQUE (status),
__FOREIGN_KEY_CONSTRAINT__,
CONSTRAINT async_task_predict_train_station_congesti_50cc24a7_status_check CHECK (status IN ('happy', 'meh', 'sad'))
);
__FOREIGN_KEY_INDEX__"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { toHashShake256Sync } from '../../../../__nonpublished_modules__/hash-fns/toHashShake256Sync';

const POSTGRES_RESOURCE_NAME_LEN_LIMIT = 63;

/**
* .what = determines an intuitive yet safe constraint name
* .why = postgres has a 63 char resource name limit
* .how = include a hash if the ideal name would be too long
*/
export const defineConstraintNameSafely = (input: {
tableName: string;
constraintName: string;
}): string => {
const idealConstraintName = [input.tableName, input.constraintName].join('_');
if (idealConstraintName.length <= POSTGRES_RESOURCE_NAME_LEN_LIMIT)
return idealConstraintName;
const hash = toHashShake256Sync(input.tableName, 4);
const tableNameLimit =
POSTGRES_RESOURCE_NAME_LEN_LIMIT -
hash.length -
input.constraintName.length -
2;
const safeConstraintName = [
input.tableName.slice(0, tableNameLimit),
hash,
input.constraintName,
].join('_');
return safeConstraintName;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Property } from '../../../../domain';
import { defineConstraintNameSafely } from './defineConstraintNameSafely';

export const generateConstraintForeignKey = ({
index,
Expand All @@ -11,13 +12,19 @@ export const generateConstraintForeignKey = ({
columnName: string;
property: Property;
}) => {
const constraintName = `${tableName}_fk${index}`;
const constraintName = `${tableName}`;
const constraintSql = `
CONSTRAINT ${constraintName} FOREIGN KEY (${columnName}) REFERENCES ${property.references!} (id)
CONSTRAINT ${defineConstraintNameSafely({
tableName,
constraintName: `fk${index}`,
})} FOREIGN KEY (${columnName}) REFERENCES ${property.references!} (id)
`.trim();

const indexSql = `
CREATE INDEX ${constraintName}_ix ON ${tableName} USING btree (${columnName});
CREATE INDEX ${defineConstraintNameSafely({
tableName,
constraintName: `fk${index}_ix`,
})} ON ${tableName} USING btree (${columnName});
`.trim();

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ describe('generateTableConstraint', () => {
}),
references: 'user',
});
const stationIdProperty = new Property({
type: new DataType({
name: DataTypeName.INT,
}),
references: 'train_station',
});
const statusProperty = prop.ENUM(['happy', 'meh', 'sad']);
it('should define the table with the correct name', () => {
const sql = generateTable({
Expand Down Expand Up @@ -110,4 +116,14 @@ describe('generateTableConstraint', () => {
);
}
});
it('should safely define the constraint names for constraints that would otherwise have names longer than 64 chars', () => {
const sql = generateTable({
tableName:
'async_task_predict_train_station_congestion_per_movement_event',
properties: { status: statusProperty, stationId: stationIdProperty },
unique: ['status'],
});
console.log(sql);
expect(sql).toMatchSnapshot();
});
});
21 changes: 14 additions & 7 deletions src/logic/generate/entityTables/generateTable/generateTable.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { toHashShake256Sync } from '../../../../__nonpublished_modules__/hash-fns/toHashShake256Sync';
import { Property } from '../../../../domain';
import { defineConstraintNameSafely } from './defineConstraintNameSafely';
import { generateColumn } from './generateColumn';
import { generateConstraintForeignKey } from './generateConstraintForeignKey';

Expand All @@ -24,12 +26,16 @@ export const generateTable = ({
);

// define primary key
const primaryKeySql = `CONSTRAINT ${tableName}_pk PRIMARY KEY (id)`;
const primaryKeySql = `CONSTRAINT ${defineConstraintNameSafely({
tableName,
constraintName: 'pk',
})} PRIMARY KEY (id)`;

// define unique index
const uniqueConstraintSql = `CONSTRAINT ${tableName}_ux1 UNIQUE (${unique.join(
', ',
)})`; // unique key definition; required since it is required for idempotency
const uniqueConstraintSql = `CONSTRAINT ${defineConstraintNameSafely({
tableName,
constraintName: 'ux1',
})} UNIQUE (${unique.join(', ')})`; // unique key definition; required since it is required for idempotency

// define foreign keys
const foreignKeySqls = Object.entries(properties)
Expand All @@ -51,9 +57,10 @@ export const generateTable = ({
const checkConstraintSqls = Object.entries(properties)
.filter((entry) => !!entry[1].check)
.map((entry) => {
return `CONSTRAINT ${tableName}_${
entry[0]
}_check CHECK ${entry[1].check!.replace(/\$COLUMN_NAME/g, entry[0])}`;
return `CONSTRAINT ${defineConstraintNameSafely({
tableName,
constraintName: `${entry[0]}_check`,
})} CHECK ${entry[1].check!.replace(/\$COLUMN_NAME/g, entry[0])}`;
})
.sort();

Expand Down

0 comments on commit 45cc443

Please sign in to comment.