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
6 changes: 4 additions & 2 deletions ghost/core/core/server/data/migrations/utils/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,14 @@ function createSetNullableMigration(table, column, options = {}) {
/**
* @param {string} table
* @param {string[]|string} columns One or multiple columns (in case the index should be for multiple columns)
* @param {object} [options]
* @param {number} [options.length] MySQL only: create a prefix index of this many characters
* @returns {Migration}
*/
function createAddIndexMigration(table, columns) {
function createAddIndexMigration(table, columns, options = {}) {
return createTransactionalMigration(
async function up(knex) {
await commands.addIndex(table, columns, knex);
await commands.addIndex(table, columns, knex, options);
},
async function down(knex) {
await commands.dropIndex(table, columns, knex);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const {createAddIndexMigration} = require('../../utils');

module.exports = createAddIndexMigration('automated_email_recipients', ['mailgun_message_id'], {length: 31});
40 changes: 37 additions & 3 deletions ghost/core/core/server/data/schema/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const tpl = require('@tryghost/tpl');
const db = require('../db');
const DatabaseInfo = require('@tryghost/database-info');
const schema = require('./schema');
const {defaultIndexName} = require('./lib/default-index-name');

const messages = {
hasPrimaryKeySQLiteError: 'Must use hasPrimaryKeySQLite on an SQLite3 database',
Expand Down Expand Up @@ -191,19 +192,38 @@ async function renameColumn(tableName, from, to, transaction = db.knex) {
});
}

/**
* Builds the column arguments for a MySQL index prefix, such as `col(123)`.
*
* @param {import('knex').Knex} knex
* @param {string|string[]} columns
* @param {number} length
* @returns {import('knex').Knex.Raw[]}
*/
function prefixIndexColumns(knex, columns, length) {
return (Array.isArray(columns) ? columns : [columns])
.map(col => knex.raw('?? (?)', [col, length]));
}

/**
* Adds an non-unique index to a table over the given columns.
*
* @param {string} tableName - name of the table to add indexes to
* @param {string|string[]} columns - column(s) to add indexes for
* @param {import('knex').Knex} [transaction] - connection object containing knex reference
* @param {object} [options]
* @param {number} [options.length] - MySQL only: create a prefix index of this many characters
*/
async function addIndex(tableName, columns, transaction = db.knex) {
async function addIndex(tableName, columns, transaction = db.knex, options = {}) {
try {
logging.info(`Adding index for '${columns}' in table '${tableName}'`);

return await transaction.schema.table(tableName, function (table) {
table.index(columns);
if (options.length && DatabaseInfo.isMySQL(transaction)) {
table.index(prefixIndexColumns(transaction, columns, options.length), defaultIndexName(tableName, columns));
} else {
table.index(columns);
}
});
} catch (err) {
if (err.code === 'SQLITE_ERROR') {
Expand Down Expand Up @@ -500,7 +520,21 @@ function createTable(table, transaction = db.knex, tableSpec = schema[table]) {
.forEach(column => addTableColumn(table, t, column, tableSpec[column]));

if (tableSpec['@@INDEXES@@']) {
tableSpec['@@INDEXES@@'].forEach(index => t.index(index));
tableSpec['@@INDEXES@@'].forEach((index) => {
if (index && typeof index === 'object' && !Array.isArray(index)) {
if (index.length && DatabaseInfo.isMySQL(transaction)) {
t.index(
prefixIndexColumns(transaction, index.columns, index.length),
defaultIndexName(table, index.columns)
);
} else {
// SQLite doesn't support prefix indexes, so we index the whole thing.
t.index(index.columns);
}
} else {
t.index(index);
}
});
}
if (tableSpec['@@UNIQUE_CONSTRAINTS@@']) {
tableSpec['@@UNIQUE_CONSTRAINTS@@'].forEach(unique => t.unique(unique));
Expand Down
20 changes: 20 additions & 0 deletions ghost/core/core/server/data/schema/lib/default-index-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Normally, we use Knex for index names. But in some cases, we need to derive
* them because Knex won't get it right. We try to match [Knex's code][0] as
* closely as possible.
*
* [0]: https://github.com/knex/knex/blob/e25d54bcb707714a17f5a5744eba5c4246bb4d1d/lib/schema/tablecompiler.js#L401-L415
*
* @param {string} tableName
* @param {string|string[]} columns
* @returns {string}
*/
function defaultIndexName(tableName, columns) {
if (!Array.isArray(columns)) {
columns = columns ? [columns] : [];
}
const table = tableName.replace(/\.|-/g, '_');
return (table + '_' + columns.join('_') + '_index').toLowerCase();
}

exports.defaultIndexName = defaultIndexName;
19 changes: 18 additions & 1 deletion ghost/core/core/server/data/schema/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -1345,7 +1345,24 @@ module.exports = {
track_opens: {type: 'boolean', nullable: false, defaultTo: false},
track_clicks: {type: 'boolean', nullable: false, defaultTo: false},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
updated_at: {type: 'dateTime', nullable: true},
'@@INDEXES@@': [
// `mailgun_message_id` is too long for a MySQL index, so we use a
// prefix.
//
// We choose 31 because Mailgun message IDs look like this:
//
// 20200420080647.ab01cd02ef03ba04@mailgun.domain.example
// YYYYMMDDHHMMSS.RANDOM-HEX-BYTES@DOMAIN
//
// That first part is unlikely to have conflicts, so let's use
// that. This index is for performance, not uniqueness, so it's
// okay if there's a conflict.
//
// Note that this prefix index only happens for MySQL. SQLite
// indexes the full value.
{columns: ['mailgun_message_id'], length: 31}
]
},
gifts: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/test/unit/server/data/schema/integrity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '2657a1cc3db66153bd743251a0430401';
const currentSchemaHash = 'c9971ddc1b9d88d7078db1d421cdb6b4';
const currentFixturesHash = '065b413e1d1f4f95fa7cb7734c5e7934';
const currentSettingsHash = '397be8628c753b1959b8954d5610f83f';
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const assert = require('node:assert/strict');
const Knex = require('knex');
const {defaultIndexName} = require('../../../../../../core/server/data/schema/lib/default-index-name');

const TEST_CASES = [
{title: 'a single column', table: 'tbl', columns: 'col'},
{title: 'multiple columns', table: 'tbl', columns: ['col_a', 'col_b']},
{title: 'a dot in the table name', table: 'foo.bar', columns: 'col'},
{title: 'a dash in the table name', table: 'foo-bar', columns: 'col'},
{title: 'uppercase table and columns', table: 'FooBar', columns: ['ColA', 'ColB']},
{title: 'a mix of dots, dashes and uppercase', table: 'Foo.Bar-Baz', columns: ['ColA', 'ColB']}
];

/**
* @param {import('knex').Knex} knex
* @param {string} table
* @param {string|string[]} columns
* @returns {string}
*/
function knexIndexName(knex, table, columns) {
const [{sql}] = knex.schema.table(table, function (t) {
t.index(columns);
}).toSQL();
return sql.match(/index `([^`]+)`/)[1];
}

describe('defaultIndexName', function () {
for (const client of ['better-sqlite3', 'mysql2']) {
describe(client, function () {
const knex = Knex({client, useNullAsDefault: true});

afterAll(function () {
return knex.destroy();
});

for (const {title, table, columns} of TEST_CASES) {
it(`matches knex for ${title}`, function () {
const actual = defaultIndexName(table, columns);
const expected = knexIndexName(knex, table, columns);
assert.equal(actual, expected);
});
}
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ describe('Data Generator', function () {
break;
} else if (rowName === '@@INDEXES@@') {
for (const indexes of row) {
table.index(indexes);
// We ignore the index prefix for SQLite.
if (indexes && typeof indexes === 'object' && !Array.isArray(indexes)) {
table.index(indexes.columns);
} else {
table.index(indexes);
}
}
break;
} else if (rowName === '@@PRIMARY_KEY@@') {
Expand Down
Loading