diff --git a/forge/ee/routes/tables/index.js b/forge/ee/routes/tables/index.js index 1c5785e036..cd0c5a1a06 100644 --- a/forge/ee/routes/tables/index.js +++ b/forge/ee/routes/tables/index.js @@ -1,3 +1,37 @@ +// The drivers turn numeric defaults into SQL with parseInt/parseFloat. A value that comes out as +// NaN or Infinity lands in the query as a bare word, which Postgres rejects with an unrelated error. +// A bigint outside its range still creates the table, but every insert relying on the default then fails. +function findInvalidNumericDefault (columns) { + return columns.find(col => { + if (!col.name || !col.default) { + return false + } + if (col.type === 'bigint') { + const value = parseInt(col.default) + return isNaN(value) || value >= 2 ** 63 || value <= -(2 ** 63) + } + if (col.type === 'real' || col.type === 'double precision') { + return !Number.isFinite(parseFloat(col.default)) + } + return false + }) +} + +// Postgres quotes a date it rejects but not the column it was for, so match the value back to it +const INVALID_DATE = /(?:invalid input syntax for type date|date\/time field value out of range): "(.*)"/ + +function findInvalidDateDefault (columns, err) { + const match = err.message.match(INVALID_DATE) + if (!match) { + return null + } + return columns.find(col => col.type === 'date' && col.default === match[1]) +} + +function invalidDefaultError (col) { + return { code: 'invalid_column_default', error: `Invalid default value "${col.default}" for ${col.type} column "${col.name}"` } +} + module.exports = async function (app) { // All routes are relative to /api/v1/teams/:teamId/databases @@ -327,6 +361,10 @@ module.exports = async function (app) { if (tables.tables.filter((t) => t.name === request.body.name && t.schema === request.body.schema).length === 1) { return reply.status(409).send({ code: 'table_exists', error: 'Table already exists' }) } + const invalidNumericDefault = findInvalidNumericDefault(request.body.columns) + if (invalidNumericDefault) { + return reply.status(400).send(invalidDefaultError(invalidNumericDefault)) + } try { await app.tables.createTable(request.team, request.params.databaseId, request.body.name, request.body.columns, request.body.schema) reply.status(201).send({ name: request.body.name, schema: request.body.schema }) @@ -335,6 +373,10 @@ module.exports = async function (app) { if (err.message.includes('Unsupported column type')) { return reply.status(400).send({ code: 'invalid_column_type', error: 'Unsupported column type. Supported types: bigint, bigserial, boolean, date, timestamptz, real, double precision, text' }) } + const invalidDateDefault = findInvalidDateDefault(request.body.columns, err) + if (invalidDateDefault) { + return reply.status(400).send(invalidDefaultError(invalidDateDefault)) + } app.log.error(`Create FF tables table error ${err.toString()}`) reply.status(500).send({ code: 'unexpected_error', error: 'Failed to create table' }) } diff --git a/test/unit/forge/ee/routes/tables/index_spec.js b/test/unit/forge/ee/routes/tables/index_spec.js index 7623da3974..a4a6829261 100644 --- a/test/unit/forge/ee/routes/tables/index_spec.js +++ b/test/unit/forge/ee/routes/tables/index_spec.js @@ -318,6 +318,75 @@ describe('Tables API', function () { response.statusCode.should.equal(400) }) + it('Fail to create a table with a numeric default that is not a number, naming the column', async function () { + const cases = [ + { name: 'count', type: 'bigint', default: 'abc' }, + { name: 'count', type: 'bigint', default: '99999999999999999999999' }, + { name: 'count', type: 'bigint', default: '9223372036854775807' }, + { name: 'count', type: 'bigint', default: '-9223372036854775808' }, + { name: 'ratio', type: 'real', default: 'abc' }, + { name: 'ratio', type: 'double precision', default: 'Infinity' } + ] + for (const column of cases) { + const spy = sinon.spy(app.tables, 'createTable') + const response = await createTable({ name: 'baddefault', columns: [column] }) + spy.restore() + response.statusCode.should.equal(400, `${column.type} default '${column.default}' should be rejected`) + response.json().should.eql({ + code: 'invalid_column_default', + error: `Invalid default value "${column.default}" for ${column.type} column "${column.name}"` + }) + spy.called.should.be.false() + } + }) + + it('Create a table with numeric defaults the driver can already use', async function () { + // parseInt/parseFloat accept a leading number, so these work today and must keep working + const spy = sinon.spy(app.tables, 'createTable') + const response = await createTable({ + name: 'numericdefaults', + columns: [ + { name: 'count', type: 'bigint', default: '12abc' }, + { name: 'big', type: 'bigint', default: '9007199254740993' }, + { name: 'ratio', type: 'real', default: '1.5' } + ] + }) + spy.restore() + response.statusCode.should.equal(201) + spy.calledOnce.should.be.true() + }) + + it('Fail to create a table with a date default Postgres rejects, naming the column', async function () { + const messages = [ + 'invalid input syntax for type date: "not-a-date"', + 'date/time field value out of range: "not-a-date"' + ] + for (const message of messages) { + const stub = sinon.stub(app.tables, 'createTable').rejects(new Error(`Failed to create table baddate for team ${TestObjects.team.hashid}: ${message}`)) + const response = await createTable({ + name: 'baddate', + columns: [ + { name: 'id', type: 'bigserial' }, + { name: 'created', type: 'date', default: 'not-a-date' } + ] + }) + stub.restore() + response.statusCode.should.equal(400) + response.json().should.eql({ + code: 'invalid_column_default', + error: 'Invalid default value "not-a-date" for date column "created"' + }) + } + }) + + it('Keeps the generic error when a driver failure is not about a column default', async function () { + const stub = sinon.stub(app.tables, 'createTable').rejects(new Error('Failed to create table other: connection refused')) + const response = await createTable({ name: 'other', columns }) + stub.restore() + response.statusCode.should.equal(500) + response.json().should.have.property('error', 'Failed to create table') + }) + it('Create a table with an empty columns list', async function () { const response = await createTable({ name: 'emptycolumns', columns: [] }) response.statusCode.should.equal(201)