Skip to content
Merged
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
42 changes: 42 additions & 0 deletions forge/ee/routes/tables/index.js
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 })
Expand All @@ -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' })
}
Expand Down
69 changes: 69 additions & 0 deletions test/unit/forge/ee/routes/tables/index_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading