From 7ade7d643563662846fb28a5417e28292920b32f Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 04:00:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84QQBot=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81SQL=E5=A5=91=E7=BA=A6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/refactor-v3/99-verify.sql | 2 +- .../qqbot-message-push-persistence.spec.ts | 28 ++- .../qqbot-message-push-sql.spec.ts | 185 +++++++++++++++--- 3 files changed, 177 insertions(+), 38 deletions(-) diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index db103da..d8ef97c 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -67,7 +67,7 @@ FROM expected_menu expected LEFT JOIN admin_menu actual ON actual.id = expected.id WHERE actual.id IS NULL OR actual.name <> expected.name - OR actual.auth_code <> expected.auth_code + OR NOT (actual.auth_code <=> expected.auth_code) OR actual.pid <> expected.pid OR actual.sort <> expected.sort OR actual.status <> 1 diff --git a/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts index a0390fd..fa5a696 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts @@ -123,6 +123,10 @@ const normalizeColumnType = (type: unknown) => { return `${type}`.toLowerCase(); }; +/** Sorts metadata tuples by property name so declaration order cannot affect the contract. */ +const sortByPropertyName = (values: readonly T[]) => + [...values].sort((left, right) => left.propertyName.localeCompare(right.propertyName)); + /** Reads an entity's TypeORM column metadata into a stable comparison tuple. */ const getColumns = (entity: EntityClass): ColumnContract[] => getMetadataArgsStorage() .columns.filter((column) => column.target === entity) @@ -138,7 +142,16 @@ const getColumns = (entity: EntityClass): ColumnContract[] => getMetadataArgsSto ? 'datetime' : normalizeColumnType(column.options.type), unsigned: column.options.unsigned === true, - })); + })) + .sort((left, right) => left.propertyName.localeCompare(right.propertyName)); + +/** Selects registered QQBot Core entities whose TypeORM table is in the message-push namespace. */ +const getRegisteredMessagePushEntities = () => QQBOT_CORE_ENTITIES + .filter((entity) => { + const table = getMetadataArgsStorage().tables.find((entry) => entry.target === entity); + return typeof table?.name === 'string' && table.name.startsWith('qqbot_message_'); + }) + .sort((left, right) => left.name.localeCompare(right.name)); /** Reads an entity's TypeORM index definitions into exact name/column/unique tuples. */ const getIndexes = (entity: EntityClass) => getMetadataArgsStorage().indices @@ -152,14 +165,16 @@ const getIndexes = (entity: EntityClass) => getMetadataArgsStorage().indices describe('QQBot message-push persistence contract', () => { it('registers exactly the six message-push entities in QQBot Core', () => { - expect(QQBOT_CORE_ENTITIES).toEqual(expect.arrayContaining(persistenceContract.map(({ entity }) => entity))); + expect(getRegisteredMessagePushEntities()).toEqual( + [...persistenceContract.map(({ entity }) => entity)].sort((left, right) => left.name.localeCompare(right.name)), + ); }); it('maps every table to its independent exact column metadata matrix', () => { for (const { columns, entity, table } of persistenceContract) { const metadataTable = getMetadataArgsStorage().tables.find((entry) => entry.target === entity); expect(metadataTable?.name).toBe(table); - expect(getColumns(entity)).toEqual(columns); + expect(getColumns(entity)).toEqual(sortByPropertyName(columns)); } }); @@ -171,11 +186,12 @@ describe('QQBot message-push persistence contract', () => { .map((column) => ({ mode: column.mode, precision: column.options.precision, + propertyName: `${column.propertyName}`, type: column.options.type === undefined ? 'datetime' : normalizeColumnType(column.options.type), })); - expect(timestamps).toEqual([ - { mode: 'createDate', precision: 6, type: 'datetime' }, - { mode: 'updateDate', precision: 6, type: 'datetime' }, + expect(sortByPropertyName(timestamps)).toEqual([ + { mode: 'createDate', precision: 6, propertyName: 'createTime', type: 'datetime' }, + { mode: 'updateDate', precision: 6, propertyName: 'updateTime', type: 'datetime' }, ]); } }); diff --git a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts index deb3487..de34b23 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts @@ -117,12 +117,46 @@ const menuEntries: readonly MenuEntry[] = [ ['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'], ]; -/** Normalizes SQL formatting while preserving the exact statement content. */ -const normalizeSql = (sql: string) => sql - .toLowerCase() - .replace(/`/g, '') - .replace(/\s+/g, ' ') - .trim(); +/** + * Normalizes SQL syntax while preserving the exact casing and whitespace inside + * single-quoted contract values. + */ +const normalizeSql = (sql: string) => { + let normalized = ''; + let quoted = false; + let previousWasWhitespace = false; + + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index]; + if (quoted) { + normalized += character; + if (character === "'" && sql[index + 1] === "'") { + normalized += sql[index + 1]; + index += 1; + } else if (character === "'" && sql[index - 1] !== '\\') { + quoted = false; + } + previousWasWhitespace = false; + continue; + } + if (character === "'") { + quoted = true; + normalized += character; + previousWasWhitespace = false; + continue; + } + if (character === '`') continue; + if (/\s/.test(character)) { + if (!previousWasWhitespace) normalized += ' '; + previousWasWhitespace = true; + continue; + } + normalized += character.toLowerCase(); + previousWasWhitespace = false; + } + + return normalized.trim(); +}; /** Reads a repository SQL file as normalized text. */ const readNormalizedSql = (relativePath: string) => @@ -146,6 +180,11 @@ const splitSqlTuple = (tuple: string) => { let quoted = false; for (let index = 0; index < tuple.length; index += 1) { const character = tuple[index]; + if (character === "'" && tuple[index + 1] === "'") { + current += tuple[index + 1]; + index += 1; + continue; + } if (character === "'" && tuple[index - 1] !== '\\') quoted = !quoted; if (character === ',' && !quoted) { values.push(current.trim()); @@ -169,6 +208,11 @@ const extractSqlTuples = (values: string) => { let quoted = false; for (let index = 0; index < values.length; index += 1) { const character = values[index]; + if (character === "'" && values[index + 1] === "'") { + current += values[index + 1]; + index += 1; + continue; + } if (character === "'" && values[index - 1] !== '\\') quoted = !quoted; if (!quoted && character === '(') { depth += 1; @@ -187,7 +231,7 @@ const extractSqlTuples = (values: string) => { return tuples; }; -/** Extracts the message-push menu VALUES rows from the matching seed statement. */ +/** Extracts every top-level VALUES row from the message-push admin-menu seed statement. */ const extractMessagePushMenuRows = (sql: string) => { const statements = [...sql.matchAll(/insert into admin_menu \([^)]*\) values (.*?) on duplicate key update/gs)]; const statement = statements.find((candidate) => candidate[1].includes(menuEntries[0][0])); @@ -200,10 +244,75 @@ const extractMessagePushMenuRows = (sql: string) => { unquoteSqlValue(fields[0]), unquoteSqlValue(fields[1]), unquoteSqlValue(fields[2]), unquoteSqlValue(fields[6]), unquoteSqlValue(fields[10]), - ] as MenuEntry) - .filter(([id]) => menuEntries.some(([expectedId]) => expectedId === id)); + ] as MenuEntry); }; +/** Splits SQL statements only at semicolons outside single-quoted values. */ +const splitSqlStatements = (sql: string) => { + const statements: string[] = []; + let current = ''; + let quoted = false; + + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index]; + current += character; + if (quoted && character === "'" && sql[index + 1] === "'") { + current += sql[index + 1]; + index += 1; + continue; + } + if (character === "'" && sql[index - 1] !== '\\') quoted = !quoted; + if (character === ';' && !quoted) { + statements.push(current.trim()); + current = ''; + } + } + if (current.trim()) statements.push(current.trim()); + return statements; +}; + +/** Locates one complete verification statement by its stable check-name literal. */ +const extractVerificationStatement = (sql: string, checkName: string) => { + const statement = splitSqlStatements(sql).find((candidate) => candidate.includes(`'${checkName}'`)); + expect(statement).toBeTruthy(); + return statement || ''; +}; + +/** Extracts the body of the expected_menu CTE without crossing quoted literals. */ +const extractExpectedMenuCte = (statement: string) => { + const cteStart = statement.match(/with expected_menu as \(/); + expect(cteStart).toBeTruthy(); + const openIndex = (cteStart?.index || 0) + (cteStart?.[0].length || 0) - 1; + let depth = 0; + let quoted = false; + for (let index = openIndex; index < statement.length; index += 1) { + const character = statement[index]; + if (quoted && character === "'" && statement[index + 1] === "'") { + index += 1; + continue; + } + if (character === "'" && statement[index - 1] !== '\\') quoted = !quoted; + if (quoted) continue; + if (character === '(') depth += 1; + if (character === ')') { + depth -= 1; + if (depth === 0) return statement.slice(openIndex + 1, index); + } + } + throw new Error('expected_menu CTE is not closed'); +}; + +/** Extracts exact five-column expected-menu tuples from the mismatch CTE. */ +const extractMismatchMenuRows = (statement: string): MenuEntry[] => { + const cte = extractExpectedMenuCte(statement); + return [...cte.matchAll(/select (\d+)(?: as id)?, (\d+)(?: as pid)?, '((?:''|[^'])*)'(?: as name)?, '((?:''|[^'])*)'(?: as auth_code)?, (\d+)(?: as sort)?/g)] + .map(([, id, pid, name, authCode, sort]) => [id, pid, name.replace(/''/g, "'"), authCode.replace(/''/g, "'"), sort] as MenuEntry); +}; + +/** Extracts exact expected-menu IDs from a cardinality or role-grant CTE. */ +const extractExpectedMenuIds = (statement: string) => + [...extractExpectedMenuCte(statement).matchAll(/select (\d+)(?: as id)?/g)].map(([, id]) => id); + describe('QQBot message-push SQL contract', () => { const bootstrapSql = readNormalizedSql('sql/qqbot-init.sql'); const schemaSql = readNormalizedSql('sql/refactor-v3/00-full-schema.sql'); @@ -211,6 +320,11 @@ describe('QQBot message-push SQL contract', () => { const verifySql = readNormalizedSql('sql/refactor-v3/99-verify.sql'); const vbenSql = readNormalizedSql('sql/vben-admin-init.sql'); + it('normalizes only SQL syntax and preserves single-quoted contract values exactly', () => { + expect(normalizeSql("SELECT `AUTH_CODE`, 'QqBot:MessageTemplate:List', '${{endpoint}}'")) + .toBe("select auth_code, 'QqBot:MessageTemplate:List', '${{endpoint}}'"); + }); + it('keeps each table DDL and index tuple exact in current and refactor schemas', () => { for (const [table, expected] of Object.entries(messagePushTableDefinitions)) { for (const sql of [bootstrapSql, schemaSql]) { @@ -256,30 +370,39 @@ describe('QQBot message-push SQL contract', () => { const rows = extractMessagePushMenuRows(sql); expect(rows).toHaveLength(18); expect(new Set(rows.map(([id]) => id)).size).toBe(18); - expect(rows).toEqual(menuEntries.map((entry) => entry.map((value) => value.toLowerCase()))); + expect(rows).toEqual(menuEntries); } }); - it('uses an inline exact menu expected set to verify rows, cardinality, and enabled-role grants', () => { - for (const [index, [id, pid, name, authCode, sort]] of menuEntries.entries()) { - expect(verifySql).toContain(normalizeSql( - `${index === 0 ? 'select' : 'union all select'} ${id}${index === 0 ? ' as id' : ''}, ${pid}${index === 0 ? ' as pid' : ''}, '${name}'${index === 0 ? ' as name' : ''}, '${authCode}'${index === 0 ? ' as auth_code' : ''}, ${sort}${index === 0 ? ' as sort' : ''}`, - )); - } - expect(verifySql).toContain('left join admin_menu actual on actual.id = expected.id'); - expect(verifySql).toContain('actual.name <> expected.name'); - expect(verifySql).toContain('actual.auth_code <> expected.auth_code'); - expect(verifySql).toContain('actual.pid <> expected.pid'); - expect(verifySql).toContain('actual.sort <> expected.sort'); - expect(verifySql).toContain('actual.status <> 1'); - expect(verifySql).toContain('actual.is_deleted <> 0'); - expect(verifySql).toContain('count(*) as expected_count'); - expect(verifySql).toContain('count(actual.id) as actual_count'); - expect(verifySql).toContain('from admin_role role cross join expected_menu expected'); - expect(verifySql).toContain('left join admin_role_menu role_menu'); - expect(verifySql).toContain("role.role_code in ('super', 'admin')"); - expect(verifySql).toContain('role.status = 1'); - expect(verifySql).toContain('role.is_deleted = 0'); - expect(verifySql).toContain('role_menu.menu_id is null'); + it('uses an exact mismatch CTE and null-safe predicates for every stable menu tuple', () => { + const mismatch = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_mismatch'); + expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries); + expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id'); + expect(mismatch).toContain('actual.name <> expected.name'); + expect(mismatch).toContain('not (actual.auth_code <=> expected.auth_code)'); + expect(mismatch).toContain('actual.pid <> expected.pid'); + expect(mismatch).toContain('actual.sort <> expected.sort'); + expect(mismatch).toContain('actual.status <> 1'); + expect(mismatch).toContain('actual.is_deleted <> 0'); + }); + + it('uses an exact cardinality CTE with expected, actual, and missing counts', () => { + const cardinality = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_cardinality'); + expect(extractExpectedMenuIds(cardinality)).toEqual(menuEntries.map(([id]) => id)); + expect(cardinality).toContain('left join admin_menu actual on actual.id = expected.id'); + expect(cardinality).toContain('count(*) as expected_count'); + expect(cardinality).toContain('count(actual.id) as actual_count'); + expect(cardinality).toContain('count(*) - count(actual.id) as missing_count'); + }); + + it('uses an exact role-grant CTE for enabled super and admin roles', () => { + const roleGrant = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_role_grant_missing'); + expect(extractExpectedMenuIds(roleGrant)).toEqual(menuEntries.map(([id]) => id)); + expect(roleGrant).toContain('from admin_role role cross join expected_menu expected'); + expect(roleGrant).toContain('left join admin_role_menu role_menu'); + expect(roleGrant).toContain("role.role_code in ('super', 'admin')"); + expect(roleGrant).toContain('role.status = 1'); + expect(roleGrant).toContain('role.is_deleted = 0'); + expect(roleGrant).toContain('role_menu.menu_id is null'); }); });