import { NetaClawDataSourceEntity } from '../src/modules/netaclaw/entity/data_source.js'; import { MysqlIntrospectionService, mapMysqlSchemaRows, } from '../src/modules/netaclaw/service/mysql_schema.js'; function makeSource(overrides: Partial = {}) { return Object.assign(new NetaClawDataSourceEntity(), { id: 42, name: 'orders', type: 'mysql', database: 'shop', extra: { allowedTables: ['customers'], blockedTables: [], maskedColumns: { 'customers.phone': 'partial' }, }, ...overrides, }); } describe('mysql schema mapper', () => { it('maps camelCase columns, primary keys, indexes, foreign keys, and masked columns', () => { const schema = mapMysqlSchemaRows({ maskedColumns: { 'customers.phone': 'partial' }, columns: [ { tableName: 'customers', tableComment: 'Customer records', columnName: 'id', ordinalPosition: 1, columnType: 'bigint unsigned', dataType: 'bigint', isNullable: 'NO', default: null, columnComment: 'Primary id', columnKey: 'PRI', }, { tableName: 'customers', tableComment: 'Customer records', columnName: 'phone', ordinalPosition: 2, columnType: 'varchar(32)', dataType: 'varchar', isNullable: 'YES', default: null, columnComment: 'Phone number', columnKey: '', }, { tableName: 'orders', tableComment: 'Orders', columnName: 'customer_id', ordinalPosition: 1, columnType: 'bigint unsigned', dataType: 'bigint', isNullable: 'NO', default: null, columnComment: '', columnKey: 'MUL', }, ], indexes: [ { tableName: 'customers', indexName: 'PRIMARY', nonUnique: 0, seqInIndex: 1, columnName: 'id', }, { tableName: 'orders', indexName: 'idx_orders_customer', nonUnique: 1, seqInIndex: 1, columnName: 'customer_id', }, ], foreignKeys: [ { tableName: 'orders', constraintName: 'fk_orders_customer', columnName: 'customer_id', referencedTableName: 'customers', referencedColumnName: 'id', ordinalPosition: 1, }, ], }); expect(schema).toEqual({ tables: [ { name: 'customers', comment: 'Customer records', columns: [ { name: 'id', type: 'bigint unsigned', dataType: 'bigint', nullable: false, default: null, comment: 'Primary id', ordinalPosition: 1, }, { name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: 'Phone number', ordinalPosition: 2, masked: true, maskMode: 'partial', }, ], primaryKey: ['id'], indexes: [ { name: 'PRIMARY', unique: true, columns: ['id'], }, ], foreignKeys: [], }, { name: 'orders', comment: 'Orders', columns: [ { name: 'customer_id', type: 'bigint unsigned', dataType: 'bigint', nullable: false, default: null, comment: '', ordinalPosition: 1, }, ], primaryKey: [], indexes: [ { name: 'idx_orders_customer', unique: false, columns: ['customer_id'], }, ], foreignKeys: [ { name: 'fk_orders_customer', columns: ['customer_id'], referencedTable: 'customers', referencedColumns: ['id'], }, ], }, ], }); }); it('keeps uppercase information_schema alias compatibility', () => { const schema = mapMysqlSchemaRows({ columns: [ { TABLE_NAME: 'customers', TABLE_COMMENT: 'Customer records', COLUMN_NAME: 'id', ORDINAL_POSITION: 1, COLUMN_TYPE: 'bigint unsigned', DATA_TYPE: 'bigint', IS_NULLABLE: 'NO', COLUMN_DEFAULT: null, COLUMN_COMMENT: 'Primary id', COLUMN_KEY: 'PRI', }, ], indexes: [ { TABLE_NAME: 'customers', INDEX_NAME: 'PRIMARY', NON_UNIQUE: 0, SEQ_IN_INDEX: 1, COLUMN_NAME: 'id', }, ], }); expect(schema.tables[0]).toMatchObject({ name: 'customers', comment: 'Customer records', columns: [ expect.objectContaining({ name: 'id', type: 'bigint unsigned', dataType: 'bigint', nullable: false, }), ], primaryKey: ['id'], indexes: [{ name: 'PRIMARY', unique: true, columns: ['id'] }], }); }); }); describe('MysqlIntrospectionService sampleTable', () => { it('defaults to unmasked visible columns when sampling a table with masked columns', async () => { const query = jest.fn().mockResolvedValue([[{ id: 1, name: 'Ada' }]]); const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue({ query }), } as any; service.listSchema = jest.fn().mockResolvedValue({ tables: [ { name: 'customers', comment: '', columns: [ { name: 'id', type: 'int', dataType: 'int', nullable: false, default: null, comment: '', ordinalPosition: 1 }, { name: 'name', type: 'varchar(64)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 2 }, { name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 3, masked: true, maskMode: 'partial' }, ], primaryKey: ['id'], indexes: [], foreignKeys: [], }, ], }); const result = await service.sampleTable(makeSource(), { table: 'customers' }); expect(query).toHaveBeenCalledWith( 'SELECT `id`, `name` FROM `customers` LIMIT ?', [5] ); expect(result).toEqual({ columns: ['id', 'name'], rows: [{ id: 1, name: 'Ada' }], rowCount: 1, truncated: false, }); }); it('preserves original camelCase column names when default sampling', async () => { const query = jest.fn().mockResolvedValue([[{ tenantId: null, isCrewMaster: 0 }]]); const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue({ query }), } as any; service.listSchema = jest.fn().mockResolvedValue({ tables: [ { name: 'netaclaw_agent', comment: '', columns: [ { name: 'tenantId', type: 'bigint', dataType: 'bigint', nullable: true, default: null, comment: '', ordinalPosition: 1 }, { name: 'isCrewMaster', type: 'tinyint', dataType: 'tinyint', nullable: false, default: 0, comment: '', ordinalPosition: 2 }, ], primaryKey: [], indexes: [], foreignKeys: [], }, ], }); const result = await service.sampleTable(makeSource({ extra: { allowedTables: ['netaclaw_agent'], blockedTables: [], }, }), { table: 'netaclaw_agent', limit: 20 }); expect(query).toHaveBeenCalledWith( 'SELECT `tenantId`, `isCrewMaster` FROM `netaclaw_agent` LIMIT ?', [20] ); expect(result.columns).toEqual(['tenantId', 'isCrewMaster']); }); it('resolves explicit camelCase columns case-insensitively for sampling', async () => { const query = jest.fn().mockResolvedValue([[{ tenantId: null }]]); const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue({ query }), } as any; service.listSchema = jest.fn().mockResolvedValue({ tables: [ { name: 'netaclaw_agent', comment: '', columns: [ { name: 'tenantId', type: 'bigint', dataType: 'bigint', nullable: true, default: null, comment: '', ordinalPosition: 1 }, ], primaryKey: [], indexes: [], foreignKeys: [], }, ], }); const result = await service.sampleTable(makeSource({ extra: { allowedTables: ['netaclaw_agent'], blockedTables: [], }, }), { table: 'netaclaw_agent', columns: ['tenantid'] }); expect(query).toHaveBeenCalledWith( 'SELECT `tenantId` FROM `netaclaw_agent` LIMIT ?', [5] ); expect(result.columns).toEqual(['tenantId']); }); it('rejects explicit masked columns when sampling', async () => { const query = jest.fn(); const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue({ query }), } as any; service.listSchema = jest.fn().mockResolvedValue({ tables: [ { name: 'customers', comment: '', columns: [ { name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 1, masked: true, maskMode: 'partial' }, ], primaryKey: [], indexes: [], foreignKeys: [], }, ], }); await expect(service.sampleTable(makeSource(), { table: 'customers', columns: ['phone'], })).rejects.toThrow('masked_column_denied'); expect(query).not.toHaveBeenCalled(); }); it('rejects default sampling when no unmasked columns remain', async () => { const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn(), } as any; service.listSchema = jest.fn().mockResolvedValue({ tables: [ { name: 'customers', comment: '', columns: [ { name: 'phone', type: 'varchar(32)', dataType: 'varchar', nullable: true, default: null, comment: '', ordinalPosition: 1, masked: true, maskMode: 'partial' }, ], primaryKey: [], indexes: [], foreignKeys: [], }, ], }); await expect(service.sampleTable(makeSource(), { table: 'customers', })).rejects.toThrow('masked_column_denied'); }); it('rejects sampling unallowed name-only tables under all-names-only schema visibility', async () => { const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn(), } as any; await expect(service.sampleTable(makeSource({ extra: { allowedTables: ['customers'], blockedTables: [], schemaVisibility: 'all-names-only', }, }), { table: 'orders', })).rejects.toThrow('table_not_allowed'); }); }); describe('MysqlIntrospectionService listSchema visibility', () => { it('returns names only for requested unblocked tables outside allowedTables when schemaVisibility is all-names-only', async () => { const query = jest.fn() .mockResolvedValueOnce([ [ { TABLE_NAME: 'customers', TABLE_COMMENT: 'Allowed customers' }, { TABLE_NAME: 'orders', TABLE_COMMENT: 'Visible name only' }, ], ]) .mockResolvedValueOnce([ [ { TABLE_NAME: 'customers', TABLE_COMMENT: 'Allowed customers', COLUMN_NAME: 'id', ORDINAL_POSITION: 1, COLUMN_TYPE: 'int', DATA_TYPE: 'int', IS_NULLABLE: 'NO', COLUMN_DEFAULT: null, COLUMN_COMMENT: '', COLUMN_KEY: 'PRI', }, ], ]) .mockResolvedValueOnce([[{ TABLE_NAME: 'customers', INDEX_NAME: 'PRIMARY', NON_UNIQUE: 0, SEQ_IN_INDEX: 1, COLUMN_NAME: 'id' }]]) .mockResolvedValueOnce([[]]); const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue({ query }), } as any; const schema = await service.listSchema(makeSource({ extra: { allowedTables: ['customers'], blockedTables: ['payments'], schemaVisibility: 'all-names-only', }, }), { tables: ['customers', 'orders', 'payments'] }); expect(schema.tables).toEqual([ { name: 'customers', comment: 'Allowed customers', columns: [ expect.objectContaining({ name: 'id' }), ], primaryKey: ['id'], indexes: [{ name: 'PRIMARY', unique: true, columns: ['id'] }], foreignKeys: [], }, { name: 'orders', comment: 'Visible name only', columns: [], primaryKey: [], indexes: [], foreignKeys: [], }, ]); expect(query).toHaveBeenNthCalledWith( 1, expect.stringContaining('information_schema.TABLES'), ['shop', 'customers', 'orders'] ); expect(query).toHaveBeenNthCalledWith( 2, expect.stringContaining('information_schema.COLUMNS'), ['shop', 'customers'] ); }); it('does not request TABLE_COMMENT from information_schema.COLUMNS', async () => { const query = jest.fn() .mockResolvedValueOnce([ [ { TABLE_NAME: 'customers', TABLE_COMMENT: 'Allowed customers' }, ], ]) .mockResolvedValueOnce([ [ { TABLE_NAME: 'customers', COLUMN_NAME: 'id', ORDINAL_POSITION: 1, COLUMN_TYPE: 'int', DATA_TYPE: 'int', IS_NULLABLE: 'NO', COLUMN_DEFAULT: null, COLUMN_COMMENT: '', COLUMN_KEY: 'PRI', }, ], ]) .mockResolvedValueOnce([[{ TABLE_NAME: 'customers', INDEX_NAME: 'PRIMARY', NON_UNIQUE: 0, SEQ_IN_INDEX: 1, COLUMN_NAME: 'id' }]]) .mockResolvedValueOnce([[]]); const service = new MysqlIntrospectionService(); service.mysqlPoolManager = { getPool: jest.fn().mockResolvedValue({ query }), } as any; const schema = await service.listSchema(makeSource()); expect(query).toHaveBeenNthCalledWith( 1, expect.stringContaining('information_schema.TABLES'), ['shop', 'customers'] ); expect(query).toHaveBeenNthCalledWith( 2, expect.not.stringContaining('TABLE_COMMENT'), ['shop', 'customers'] ); expect(schema.tables[0].comment).toBe('Allowed customers'); }); });