diff --git a/lib/mysql.js b/lib/mysql.js index 89487a0..9fb778d 100644 --- a/lib/mysql.js +++ b/lib/mysql.js @@ -97,7 +97,8 @@ class Mysql extends Sql { const queryLogger = createQueryLogger({ queryLogThreshold: this.queryLogThreshold, timeoutLogLevel: this.timeoutLogLevel, - logger: loggerToUse + logger: loggerToUse, + dialect: 'mysql' }); const pool = this.pool; const request = { @@ -106,6 +107,7 @@ class Mysql extends Sql { input: this.input, params: {}, _logger: loggerToUse, + _sqlDialect: 'mysql', }; return request; } diff --git a/lib/sql.js b/lib/sql.js index 3c2a25e..b01e40d 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -1,4 +1,6 @@ import mssql from 'mssql'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; import fs from 'fs-extra'; import zlib from 'zlib'; import logger from './logger.js'; @@ -7,18 +9,262 @@ import config from './appConfig.mjs'; import enums from './enums.mjs'; const { maxQueryTime = 500 } = config || {}; const { inOperatorStrategies, dateTimeFields, columnTypes } = enums; +dayjs.extend(utc); const isNullNotNullOperators = ['IS NOT NULL', 'IS NULL']; -const createQueryLogger = function ({ queryLogThreshold, timeoutLogLevel, logger }) { +const mssqlSqlTypeMap = { + BigInt: 'BIGINT', + Binary: 'BINARY', + Bit: 'BIT', + Char: 'CHAR', + Date: 'DATE', + DateTime: 'DATETIME', + DateTime2: 'DATETIME2', + DateTimeOffset: 'DATETIMEOFFSET', + Decimal: 'DECIMAL', + Float: 'FLOAT', + Image: 'IMAGE', + Int: 'INT', + Money: 'MONEY', + NChar: 'NCHAR', + NText: 'NTEXT', + Numeric: 'NUMERIC', + NVarChar: 'NVARCHAR', + Real: 'REAL', + SmallDateTime: 'SMALLDATETIME', + SmallInt: 'SMALLINT', + SmallMoney: 'SMALLMONEY', + Text: 'TEXT', + Time: 'TIME', + TinyInt: 'TINYINT', + UniqueIdentifier: 'UNIQUEIDENTIFIER', + VarBinary: 'VARBINARY', + VarChar: 'VARCHAR', + Xml: 'XML' +}; + +const maxLengthSqlTypes = new Set(['VARCHAR', 'NVARCHAR', 'VARBINARY']); +const inferredLengthSqlTypes = new Set(['CHAR', 'NCHAR', 'BINARY']); + +const stringifySqlValue = (value) => { + if (value === null || value === undefined) { + return 'NULL'; + } + if (typeof value === 'string') { + return `'${value.replace(/'/g, "''")}'`; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : 'NULL'; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + if (value instanceof Date) { + return `'${dayjs(value).utc().format('YYYY-MM-DD HH:mm:ss.SSS[Z]')}'`; + } + if (Buffer.isBuffer(value)) { + return `0x${value.toString('hex')}`; + } + try { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + return `'[Unserializable]'`; + } + return `'${serialized.replace(/'/g, "''")}'`; + } catch (err) { + const safeMessage = err?.message ? String(err.message).replace(/'/g, "''") : 'Unable to serialize value'; + return `'[Unserializable: ${safeMessage}]'`; + } +}; + +const getSqlTypeFromParameter = (parameter) => { + const type = parameter?.type?.type || parameter?.type; + const typeName = type?.name; + const sqlType = mssqlSqlTypeMap[typeName] || typeName?.toUpperCase() || 'NVARCHAR(MAX)'; + if (maxLengthSqlTypes.has(sqlType)) { + // The mssql package uses mssql.MAX as the sentinel for MAX-length variable types. + if (parameter?.length === mssql.MAX) { + return `${sqlType}(MAX)`; + } + if (Number.isFinite(parameter?.length)) { + return `${sqlType}(${parameter.length})`; + } + // Default to MAX when length is omitted so the logged SQL stays executable without truncation. + return `${sqlType}(MAX)`; + } + if (inferredLengthSqlTypes.has(sqlType)) { + if (Number.isFinite(parameter?.length)) { + return `${sqlType}(${parameter.length})`; + } + if (typeof parameter?.value === 'string') { + return `${sqlType}(${Math.max(parameter.value.length, 1)})`; + } + if (Buffer.isBuffer(parameter?.value)) { + return `${sqlType}(${Math.max(parameter.value.length, 1)})`; + } + return `${sqlType}(1)`; + } + if (sqlType === 'DECIMAL' || sqlType === 'NUMERIC') { + if (Number.isFinite(parameter?.precision) && Number.isFinite(parameter?.scale)) { + return `${sqlType}(${parameter.precision},${parameter.scale})`; + } + if (Number.isFinite(parameter?.precision)) { + return `${sqlType}(${parameter.precision})`; + } + } + if ((sqlType === 'DATETIME2' || sqlType === 'TIME' || sqlType === 'DATETIMEOFFSET') && Number.isFinite(parameter?.scale)) { + return `${sqlType}(${parameter.scale})`; + } + return sqlType; +}; + +const toParameterDescriptor = (name, parameter) => { + if (parameter && typeof parameter === 'object' && ('value' in parameter || 'type' in parameter)) { + return { name: parameter.name || name, ...parameter }; + } + return { name, value: parameter }; +}; + +const getTvpPath = (table) => { + if (table?.path) { + return table.path; + } + if (table?.schema && table?.name) { + return `[${table.schema}].[${table.name}]`; + } + if (table?.name) { + return `[${table.name}]`; + } + return '[dbo].[UnknownTableType]'; +}; + +const formatTvpParameter = (descriptor) => { + const tvp = descriptor.value; + const columnNames = (tvp.columns || []).map((column) => column.name); + const rows = tvp.rows || []; + const lines = [`DECLARE @${descriptor.name} ${getTvpPath(tvp)}`]; + if (columnNames.length === 0 || rows.length === 0) { + return lines; + } + const columnsClause = columnNames.map((name) => `[${name}]`).join(', '); + const rowBatchSize = 100; + for (let index = 0; index < rows.length; index += rowBatchSize) { + const batchRows = rows.slice(index, index + rowBatchSize); + const valuesClause = batchRows.map((row) => { + const values = columnNames.map((_, colIndex) => stringifySqlValue(row[colIndex])); + return `(${values.join(', ')})`; + }).join(',\n'); + lines.push(`INSERT INTO @${descriptor.name} (${columnsClause}) VALUES\n${valuesClause};`); + } + return lines; +}; + +const formatSqlQueryForLog = ({ query, parameters } = {}) => { + const params = parameters || {}; + const descriptors = Object.entries(params).map(([name, parameter]) => toParameterDescriptor(name, parameter)); + const tvpDescriptors = descriptors.filter((descriptor) => descriptor.value instanceof mssql.Table); + const scalarDeclareLines = descriptors.filter((descriptor) => !(descriptor.value instanceof mssql.Table)).map((descriptor) => { + const sqlType = getSqlTypeFromParameter(descriptor); + const sqlValue = stringifySqlValue(descriptor.value); + return `DECLARE @${descriptor.name} ${sqlType} = ${sqlValue}`; + }); + const tvpLines = tvpDescriptors.flatMap((descriptor) => formatTvpParameter(descriptor)); + const declareLines = [...tvpLines, ...scalarDeclareLines]; + if (declareLines.length === 0) { + return query || ''; + } + return `${declareLines.join('\n')}\n\n${query || ''}`; +}; + +const formatQueryForLog = ({ query, parameters, dialect = 'mssql' } = {}) => { + return dialect === 'mssql' ? formatSqlQueryForLog({ query, parameters }) : (query || ''); +}; + +const summarizeTvpParameter = (table) => ({ + type: getTvpPath(table), + columns: (table?.columns || []).map((column) => column.name), + rowCount: table?.rows?.length || 0 +}); + +const normalizeValueForJsonLog = (value) => { + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Buffer.isBuffer(value)) { + return `0x${value.toString('hex')}`; + } + if (value === null || value === undefined || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + const seen = new WeakSet(); + try { + const serialized = JSON.stringify(value, (_, currentValue) => { + if (typeof currentValue === 'bigint') { + return currentValue.toString(); + } + if (typeof currentValue === 'function' || typeof currentValue === 'symbol') { + return '[Unserializable]'; + } + if (currentValue && typeof currentValue === 'object') { + if (seen.has(currentValue)) { + return '[Circular]'; + } + seen.add(currentValue); + } + return currentValue; + }); + if (serialized === undefined) { + return '[Unserializable]'; + } + return JSON.parse(serialized); + } catch { + return '[Unserializable]'; + } +}; + +const summarizeParametersForLog = (parameters) => { + if (!parameters || typeof parameters !== 'object') { + return parameters; + } + return Object.fromEntries(Object.entries(parameters).map(([name, parameter]) => { + const descriptor = toParameterDescriptor(name, parameter); + const parameterName = descriptor.name || name; + if (descriptor.value instanceof mssql.Table) { + return [parameterName, summarizeTvpParameter(descriptor.value)]; + } + if (parameter && typeof parameter === 'object' && ('value' in parameter || 'type' in parameter)) { + return [parameterName, { ...parameter, name: parameterName, value: normalizeValueForJsonLog(descriptor.value) }]; + } + return [parameterName, normalizeValueForJsonLog(descriptor.value)]; + })); +}; + +const getRequestDialect = (request) => { + if (request?._sqlDialect) { + return request._sqlDialect; + } + // Fallback for older/custom request objects that do not set _sqlDialect: + // MySQL requests in this codebase carry plain `params`, while MSSQL requests carry `parameters`. + return Object.prototype.hasOwnProperty.call(request || {}, 'params') ? 'mysql' : 'mssql'; +}; + +const createQueryLogger = function ({ queryLogThreshold, timeoutLogLevel, logger, dialect = 'mssql' }) { return async function ({ query, start, end = Date.now(), parameters }) { const queryDurationInMs = (end - start); if (queryDurationInMs > queryLogThreshold) { - logger[timeoutLogLevel]({ - query: query, - duration: `${queryDurationInMs}ms`, - parameters: parameters - }); + const formattedQuery = formatQueryForLog({ query, parameters, dialect }); + const parametersForLog = summarizeParametersForLog(parameters); + logger[timeoutLogLevel]( + { query, duration: `${queryDurationInMs}ms`, durationMs: queryDurationInMs, parameters: parametersForLog, formattedQuery, dialect }, + `SQL query duration ${queryDurationInMs}ms` + ); } }; } @@ -121,8 +367,10 @@ class Sql { return { success: true, data: result.recordset, ...result }; } catch (err) { const loggerToUse = request._logger || this.logger; - loggerToUse.error({ err, query, parameters: request.parameters, type }); - return { success: false, err, data: {} }; + const parameters = summarizeParametersForLog(request.parameters); + const formattedQuery = formatSqlQueryForLog({ query, parameters: request.parameters }); + loggerToUse.error({ err, type, query, parameters, formattedQuery }, 'SQL query failed'); + return { success: false, err, data: {} }; } } @@ -130,13 +378,14 @@ class Sql { const executionTime = Date.now() - startTime; if (executionTime > maxQueryTime) { // 500 milliseconds const loggerToUse = request._logger || this.logger; - loggerToUse.warn({ - message: `Query execution exceeded ${maxQueryTime} milliseconds`, - query, - executionTime: `${executionTime}ms`, - type, - parameters: request.parameters || request.params - }); + const parameters = request.parameters || request.params; + const dialect = getRequestDialect(request); + const formattedQuery = formatQueryForLog({ query, parameters, dialect }); + const parametersForLog = summarizeParametersForLog(parameters); + loggerToUse.warn( + { query, type, executionTime: `${executionTime}ms`, executionTimeMs: executionTime, parameters: parametersForLog, formattedQuery, dialect }, + `Query execution exceeded ${maxQueryTime} milliseconds (${executionTime}ms) [${type}]` + ); } } @@ -790,7 +1039,7 @@ class Sql { const queryStartTime = Date.now(); const returnValue = await Reflect.apply(target, thisArg, args); const [query] = args; - queryLogger({ query, start: queryStartTime, end: Date.now(), parameters: thisArg.parameters }); + queryLogger({ query, start: queryStartTime, end: Date.now(), parameters: thisArg.parameters || thisArg.params }); return returnValue; } }); @@ -801,12 +1050,14 @@ class Sql { const queryLogger = createQueryLogger({ queryLogThreshold: this.queryLogThreshold, timeoutLogLevel: this.timeoutLogLevel, - logger: loggerToUse + logger: loggerToUse, + dialect: 'mssql' }); const request = this.pool.request(); request.query = this.createProxy(request.query, queryLogger); request.execute = this.createProxy(request.execute, queryLogger); request._logger = loggerToUse; + request._sqlDialect = 'mssql'; return request; } @@ -896,4 +1147,4 @@ class Sql { export default Sql; -export { mssql, createQueryLogger }; \ No newline at end of file +export { mssql, createQueryLogger, formatSqlQueryForLog }; diff --git a/tests/mysql-create-request.test.js b/tests/mysql-create-request.test.js index eda91ff..91bf6df 100644 --- a/tests/mysql-create-request.test.js +++ b/tests/mysql-create-request.test.js @@ -51,6 +51,8 @@ test('Each createRequest returns a fresh independent object with its own params' const req1 = mysql2.createRequest(); const req2 = mysql2.createRequest(); + assert.strictEqual(req1._sqlDialect, 'mysql'); + assert.strictEqual(req2._sqlDialect, 'mysql'); req1.params['a'] = 1; assert.strictEqual(req2.params['a'], undefined); }); diff --git a/tests/sql-log-format.test.js b/tests/sql-log-format.test.js new file mode 100644 index 0000000..84c0e93 --- /dev/null +++ b/tests/sql-log-format.test.js @@ -0,0 +1,261 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Sql, { createQueryLogger, formatSqlQueryForLog, mssql } from '../lib/sql.js'; + +const buildParameters = () => { + const request = new mssql.Request(); + request.input('Id', mssql.Int, 5); + request.input('Name', mssql.VarChar(20), "O'Brien"); + request.input('CreatedOn', mssql.DateTime2, new Date('2025-01-02T03:04:05.678Z')); + request.input('IsActive', mssql.Bit, true); + request.input('OptionalValue', mssql.Int, null); + return request.parameters; +}; + +test('formatSqlQueryForLog prints DECLARE statements and readable SQL', () => { + const query = 'SELECT *\nFROM Users\tWHERE Id = @Id'; + const formattedQuery = formatSqlQueryForLog({ query, parameters: buildParameters() }); + + assert.match(formattedQuery, /DECLARE @Id INT = 5/); + assert.match(formattedQuery, /DECLARE @Name VARCHAR\(20\) = 'O''Brien'/); + assert.match(formattedQuery, /DECLARE @CreatedOn DATETIME2 = '2025-01-02 03:04:05\.678Z'/); + assert.match(formattedQuery, /DECLARE @IsActive BIT = 1/); + assert.match(formattedQuery, /DECLARE @OptionalValue INT = NULL/); + assert.ok(formattedQuery.endsWith(query)); +}); + +test('formatSqlQueryForLog renders TVP as DECLARE + INSERT statements (not JSON)', () => { + const request = new mssql.Request(); + const tvp = new mssql.Table('dbo.StringList'); + tvp.columns.add('Value', mssql.VarChar(500), { nullable: false }); + tvp.rows.add('Alpha'); + tvp.rows.add("Bob's Item"); + request.input('ListParam', tvp); + request.input('Id', mssql.Int, 7); + + const query = 'SELECT *\nFROM dbo.Users\tWHERE UserId = @Id'; + const formattedQuery = formatSqlQueryForLog({ query, parameters: request.parameters }); + + assert.match(formattedQuery, /DECLARE @ListParam \[dbo\]\.\[StringList\]/); + assert.match(formattedQuery, /INSERT INTO @ListParam \(\[Value\]\) VALUES/); + assert.match(formattedQuery, /\('Alpha'\),\n\('Bob''s Item'\);/); + assert.match(formattedQuery, /DECLARE @Id INT = 7/); + assert.ok(!formattedQuery.includes('"columns"')); + assert.ok(!formattedQuery.includes('"rows"')); + assert.ok(formattedQuery.endsWith(query)); +}); + +test('TVP INSERT statements are batched at 100 rows', () => { + const request = new mssql.Request(); + const tvp = new mssql.Table('dbo.IntList'); + tvp.columns.add('Value', mssql.Int, { nullable: false }); + tvp.columns.add('Sequence', mssql.Int, { nullable: false }); + for (let i = 1; i <= 101; i++) { + tvp.rows.add(i, i); + } + request.input('Ids', tvp); + + const formattedQuery = formatSqlQueryForLog({ query: 'SELECT 1', parameters: request.parameters }); + + const insertStatementCount = (formattedQuery.match(/INSERT INTO @Ids/g) || []).length; + assert.strictEqual(insertStatementCount, 2); + assert.match(formattedQuery, /\(100, 100\)/); + assert.match(formattedQuery, /\(101, 101\)/); +}); + +test('createQueryLogger logs formatted multiline SQL when threshold is exceeded', async () => { + const calls = []; + const logger = { + warn: (...args) => calls.push(args) + }; + const queryLogger = createQueryLogger({ queryLogThreshold: 5, timeoutLogLevel: 'warn', logger }); + const query = 'SELECT *\nFROM Users\tWHERE Id = @Id'; + + await queryLogger({ + query, + start: 100, + end: 200, + parameters: buildParameters() + }); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0][0].duration, '100ms'); + assert.strictEqual(calls[0][0].durationMs, 100); + assert.strictEqual(calls[0][0].dialect, 'mssql'); + assert.match(calls[0][0].formattedQuery, /DECLARE @Id INT = 5/); + assert.match(calls[0][1], /SQL query duration 100ms/); +}); + +test('slow-query and error log sites use formatted SQL output', async () => { + const warnCalls = []; + const errorCalls = []; + const mockLogger = { + warn: (...args) => warnCalls.push(args), + error: (...args) => errorCalls.push(args) + }; + const sql = new Sql(); + const query = 'SELECT *\nFROM Users\tWHERE Id = @Id'; + const parameters = buildParameters(); + + const originalDateNow = Date.now; + Date.now = () => 1000; + try { + sql.logSlowQuery({ + startTime: 100, + query, + type: 'query', + request: { _logger: mockLogger, parameters } + }); + } finally { + Date.now = originalDateNow; + } + + assert.strictEqual(warnCalls.length, 1); + assert.strictEqual(warnCalls[0][0].executionTime, '900ms'); + assert.strictEqual(warnCalls[0][0].executionTimeMs, 900); + assert.strictEqual(warnCalls[0][0].type, 'query'); + assert.match(warnCalls[0][0].formattedQuery, /DECLARE @Id INT = 5/); + assert.match(warnCalls[0][1], /Query execution exceeded 500 milliseconds/); + + const expectedError = new Error('forced failure'); + const result = await sql.runQuery({ + request: { + _logger: mockLogger, + parameters, + query: async () => { + throw expectedError; + } + }, + type: 'query', + query + }); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.err, expectedError); + assert.strictEqual(errorCalls.length, 1); + assert.strictEqual(errorCalls[0][0].err, expectedError); + assert.strictEqual(errorCalls[0][0].type, 'query'); + assert.strictEqual(errorCalls[0][0].query, query); + assert.match(errorCalls[0][0].formattedQuery, /DECLARE @Id INT = 5/); + assert.strictEqual(errorCalls[0][0].parameters.Id.value, 5); + assert.match(errorCalls[0][1], /SQL query failed/); + assert.strictEqual(errorCalls[0][1], 'SQL query failed'); +}); + +test('formatSqlQueryForLog safely handles bigint and unserializable values', () => { + const circular = {}; + circular.self = circular; + const query = formatSqlQueryForLog({ + query: 'SELECT @Big, @Circular', + parameters: { + Big: { type: mssql.BigInt, value: 9007199254740993n }, + Circular: { value: circular } + } + }); + assert.match(query, /DECLARE @Big BIGINT = 9007199254740993/); + assert.match(query, /DECLARE @Circular NVARCHAR\(MAX\) = '\[Unserializable:/); +}); + +test('createQueryLogger preserves raw SQL formatting for mysql dialect', async () => { + const calls = []; + const logger = { warn: (...args) => calls.push(args) }; + const queryLogger = createQueryLogger({ queryLogThreshold: 1, timeoutLogLevel: 'warn', logger, dialect: 'mysql' }); + await queryLogger({ + query: 'SELECT * FROM users WHERE id = :id', + start: 10, + end: 20, + parameters: { id: 5 } + }); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0][0].dialect, 'mysql'); + assert.strictEqual(calls[0][0].formattedQuery, 'SELECT * FROM users WHERE id = :id'); + assert.ok(!calls[0][1].includes('DECLARE @')); +}); + +test('formatSqlQueryForLog uses safe default lengths when MSSQL parameter length is omitted', () => { + const formattedQuery = formatSqlQueryForLog({ + query: 'SELECT @Name, @Code', + parameters: { + Name: { type: mssql.VarChar, value: 'Alpha' }, + Code: { type: mssql.Char, value: 'AB' } + } + }); + + assert.match(formattedQuery, /DECLARE @Name VARCHAR\(MAX\) = 'Alpha'/); + assert.match(formattedQuery, /DECLARE @Code CHAR\(2\) = 'AB'/); +}); + +test('structured logs summarize TVPs and keep mysql slow-query SQL raw', () => { + const tvp = new mssql.Table('dbo.IntList'); + tvp.columns.add('Value', mssql.Int, { nullable: false }); + tvp.rows.add(1); + tvp.rows.add(2); + + const durationCalls = []; + const durationLogger = { warn: (...args) => durationCalls.push(args) }; + const queryLogger = createQueryLogger({ queryLogThreshold: 1, timeoutLogLevel: 'warn', logger: durationLogger }); + + return Promise.resolve(queryLogger({ + query: 'SELECT * FROM dbo.Users WHERE Id IN (SELECT Value FROM @Ids)', + start: 0, + end: 25, + parameters: { + Ids: { name: 'Ids', value: tvp } + } + })).then(() => { + assert.deepStrictEqual(durationCalls[0][0].parameters.Ids, { + type: '[dbo].[IntList]', + columns: ['Value'], + rowCount: 2 + }); + + const warnCalls = []; + const mysqlLogger = { warn: (...args) => warnCalls.push(args) }; + const sql = new Sql(); + const originalDateNow = Date.now; + Date.now = () => 1000; + try { + sql.logSlowQuery({ + startTime: 100, + query: 'SELECT * FROM users WHERE id = :id', + type: 'query', + request: { _logger: mysqlLogger, _sqlDialect: 'mysql', params: { id: 5 } } + }); + } finally { + Date.now = originalDateNow; + } + + assert.strictEqual(warnCalls[0][0].dialect, 'mysql'); + assert.strictEqual(warnCalls[0][0].formattedQuery, 'SELECT * FROM users WHERE id = :id'); + assert.ok(!warnCalls[0][1].includes('DECLARE @')); + }); +}); + +test('summarizeParametersForLog uses descriptor name and JSON-safe scalar values', () => { + const calls = []; + const log = { error: (...args) => calls.push(args) }; + const sql = new Sql(); + const circular = {}; + circular.self = circular; + + return sql.runQuery({ + request: { + _logger: log, + parameters: { + Alias: { name: 'ActualAlias', type: mssql.Int, value: 9 }, + BigValue: { type: mssql.BigInt, value: 9007199254740993n }, + Circular: { value: circular } + }, + query: async () => { + throw new Error('boom'); + } + }, + type: 'query', + query: 'SELECT 1' + }).then(() => { + assert.ok(calls[0][0].parameters.ActualAlias); + assert.strictEqual(calls[0][0].parameters.Alias, undefined); + assert.strictEqual(calls[0][0].parameters.BigValue.value, '9007199254740993'); + assert.strictEqual(calls[0][0].parameters.Circular.value.self, '[Circular]'); + }); +});