diff --git a/ghost/core/core/server/data/migrations/utils/schema.js b/ghost/core/core/server/data/migrations/utils/schema.js index 860ffa0a8da..298fe27dc98 100644 --- a/ghost/core/core/server/data/migrations/utils/schema.js +++ b/ghost/core/core/server/data/migrations/utils/schema.js @@ -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); diff --git a/ghost/core/core/server/data/migrations/versions/6.54/2026-07-21-21-08-15-add-automated-email-recipients-mailgun-message-id-index.js b/ghost/core/core/server/data/migrations/versions/6.54/2026-07-21-21-08-15-add-automated-email-recipients-mailgun-message-id-index.js new file mode 100644 index 00000000000..4c752541980 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.54/2026-07-21-21-08-15-add-automated-email-recipients-mailgun-message-id-index.js @@ -0,0 +1,3 @@ +const {createAddIndexMigration} = require('../../utils'); + +module.exports = createAddIndexMigration('automated_email_recipients', ['mailgun_message_id'], {length: 31}); diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index 1db1a5b6733..c9cc90be0fe 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -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', @@ -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') { @@ -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)); diff --git a/ghost/core/core/server/data/schema/lib/default-index-name.js b/ghost/core/core/server/data/schema/lib/default-index-name.js new file mode 100644 index 00000000000..b1b22e912c2 --- /dev/null +++ b/ghost/core/core/server/data/schema/lib/default-index-name.js @@ -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; diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 383c1058429..446f601ce49 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -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}, diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index b55636a2fbe..0214a7b4c45 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -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'; diff --git a/ghost/core/test/unit/server/data/schema/lib/default-index-name.test.js b/ghost/core/test/unit/server/data/schema/lib/default-index-name.test.js new file mode 100644 index 00000000000..a00a00e661f --- /dev/null +++ b/ghost/core/test/unit/server/data/schema/lib/default-index-name.test.js @@ -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); + }); + } + }); + } +}); diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 4cf88443e26..ca33abba690 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -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@@') {