diff --git a/packages/nodes-base/credentials/OracleDBApi.credentials.ts b/packages/nodes-base/credentials/OracleDBApi.credentials.ts index f3c0b1a2b311a..bf9b1be724c91 100644 --- a/packages/nodes-base/credentials/OracleDBApi.credentials.ts +++ b/packages/nodes-base/credentials/OracleDBApi.credentials.ts @@ -1,4 +1,21 @@ import type { ICredentialType, INodeProperties } from 'n8n-workflow'; +import oracledb from 'oracledb'; + +const privilegeKeys = [ + 'SYSASM', + 'SYSBACKUP', + 'SYSDBA', + 'SYSDG', + 'SYSKM', + 'SYSOPER', + 'SYSPRELIM', + 'SYSRAC', +]; + +const privilegeOptions = privilegeKeys.map((key) => ({ + name: key, + value: (oracledb as any)[key], +})); export class OracleDBApi implements ICredentialType { name = 'oracleDBApi'; @@ -30,6 +47,14 @@ export class OracleDBApi implements ICredentialType { default: 'localhost/orcl', description: 'The Oracle database instance to connect to', }, + { + displayName: 'Privilege', + name: 'privilege', + type: 'options', + description: 'The privilege to use when connecting to the database', + default: undefined, + options: privilegeOptions, + }, { displayName: 'Use Optional Oracle Client Libraries', name: 'useThickMode', diff --git a/packages/nodes-base/nodes/Oracle/Sql/actions/database/select.operation.ts b/packages/nodes-base/nodes/Oracle/Sql/actions/database/select.operation.ts index 5876fff854a0a..d5b28a5f098fa 100644 --- a/packages/nodes-base/nodes/Oracle/Sql/actions/database/select.operation.ts +++ b/packages/nodes-base/nodes/Oracle/Sql/actions/database/select.operation.ts @@ -87,6 +87,10 @@ export async function execute( ): Promise { const queries: QueryWithValues[] = []; + const conn = await pool.getConnection(); + const isCDBSupported = conn.oracleServerVersion >= 1200000000; + await conn.close(); + for (let i = 0; i < items.length; i++) { const schema = this.getNodeParameter('schema', i, undefined, { extractValue: true, @@ -102,26 +106,46 @@ export async function execute( const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[]; let query = ''; - if (outputColumns.includes('*')) { - query = `SELECT * FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}`; - } else { - const quotedColumns = outputColumns.map(quoteSqlIdentifier).join(','); - query = `SELECT ${quotedColumns} FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}`; - } + let innerQuery = outputColumns.includes('*') + ? `SELECT * FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}` + : `SELECT ${outputColumns.map(quoteSqlIdentifier).join(',')} FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}`; + // Add WHERE clause const whereClauses = ((this.getNodeParameter('where', i, []) as IDataObject).values as WhereClause[]) || []; const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string; - [query, values] = addWhereClauses(query, whereClauses, combineConditions, columnMetaDataObject); + [innerQuery, values] = addWhereClauses( + innerQuery, + whereClauses, + combineConditions, + columnMetaDataObject, + ); + // Add ORDER BY if needed const sortRules = ((this.getNodeParameter('sort', i, []) as IDataObject).values as SortRule[]) || []; - query = addSortRules(query, sortRules); + innerQuery = addSortRules(innerQuery, sortRules); + // Handle LIMIT / pagination const returnAll = this.getNodeParameter('returnAll', i, false); if (!returnAll) { const limit = this.getNodeParameter('limit', i, 50); - query += ` FETCH FIRST ${limit} ROWS ONLY`; + + if (isCDBSupported) { + // Oracle 12c+ (FETCH FIRST) + query += `${innerQuery} FETCH FIRST ${limit} ROWS ONLY`; + } else { + if (sortRules.length > 0 || whereClauses.length > 0) { + // Wrap inner query to preserve WHERE + ORDER BY + query = `SELECT * FROM (${innerQuery}) WHERE ROWNUM <= ${limit}`; + } else { + // No ORDER BY or WHERE: safe to append ROWNUM inline + query = `${innerQuery} WHERE ROWNUM <= ${limit}`; + } + } + } else { + // return all: no limit + query = innerQuery; } const queryWithValues = { query, values }; diff --git a/packages/nodes-base/nodes/Oracle/Sql/helpers/interfaces.ts b/packages/nodes-base/nodes/Oracle/Sql/helpers/interfaces.ts index 292a382f96adb..b6f455ac7faa6 100644 --- a/packages/nodes-base/nodes/Oracle/Sql/helpers/interfaces.ts +++ b/packages/nodes-base/nodes/Oracle/Sql/helpers/interfaces.ts @@ -79,6 +79,7 @@ export type OracleDBNodeCredentials = { poolMin: number; poolMax: number; poolIncrement: number; + privilege?: number; sslServerCertDN?: string; sslServerDNMatch?: boolean; sslAllowWeakDNMatch?: boolean; @@ -122,3 +123,16 @@ export type ExecuteOpBindParam = values: number[]; }; }); + +// Definition of row returned for column information. +export interface TableColumnRow { + COLUMN_NAME: string; + DATA_TYPE: string; + DATA_LENGTH: number; + CHAR_LENGTH: number; + DEFAULT_LENGTH: number | null; + NULLABLE: 'Y' | 'N'; + IDENTITY_COLUMN?: 'YES' | 'NO'; // only present in 12c+ + HAS_DEFAULT: 'YES' | 'NO'; + CONSTRAINT_TYPES?: string | null; +} diff --git a/packages/nodes-base/nodes/Oracle/Sql/helpers/utils.ts b/packages/nodes-base/nodes/Oracle/Sql/helpers/utils.ts index c632c33639ed8..ef561f6d0f1a9 100644 --- a/packages/nodes-base/nodes/Oracle/Sql/helpers/utils.ts +++ b/packages/nodes-base/nodes/Oracle/Sql/helpers/utils.ts @@ -24,6 +24,7 @@ import type { SortRule, ColumnMap, OracleDBNodeOptions, + TableColumnRow, } from './interfaces'; const n8nTypetoDBType: { [key: string]: oracledb.DbType } = { @@ -240,13 +241,17 @@ export async function getColumnMetaData( let conn: oracledb.Connection | undefined; try { conn = await pool.getConnection(); - const sql = `WITH constraint_info AS ( + + const isCDBSupported = conn.oracleServerVersion >= 1200000000; + + const sql = isCDBSupported + ? ` +WITH constraint_info AS ( SELECT acc.owner, acc.table_name, acc.column_name, - LISTAGG(ac.constraint_type, ',') - WITHIN GROUP (ORDER BY ac.constraint_type) AS constraint_types + LISTAGG(ac.constraint_type, ',') WITHIN GROUP (ORDER BY ac.constraint_type) AS constraint_types FROM all_cons_columns acc JOIN all_constraints ac ON acc.constraint_name = ac.constraint_name @@ -264,6 +269,42 @@ SELECT CASE WHEN atc.DATA_DEFAULT IS NOT NULL THEN 'YES' ELSE 'NO' END AS HAS_DEFAULT, ci.constraint_types FROM all_tab_columns atc +LEFT JOIN constraint_info ci + ON atc.owner = ci.owner + AND atc.table_name = ci.table_name + AND atc.column_name = ci.column_name +WHERE atc.owner = :schema_name + AND atc.table_name = :table_name +ORDER BY atc.COLUMN_NAME` + : ` +WITH constraint_info AS ( + SELECT + acc.owner, + acc.table_name, + acc.column_name, + RTRIM( + XMLAGG( + XMLELEMENT(e, ac.constraint_type || ',') + ORDER BY ac.constraint_type + ).EXTRACT('//text()').getClobVal(), + ',' + ) AS constraint_types + FROM all_cons_columns acc + JOIN all_constraints ac + ON acc.constraint_name = ac.constraint_name + AND acc.owner = ac.owner + GROUP BY acc.owner, acc.table_name, acc.column_name +) +SELECT + atc.COLUMN_NAME, + atc.DATA_TYPE, + atc.DATA_LENGTH, + atc.CHAR_LENGTH, + atc.DEFAULT_LENGTH, + atc.NULLABLE, + CASE WHEN atc.DATA_DEFAULT IS NOT NULL THEN 'YES' ELSE 'NO' END AS HAS_DEFAULT, + ci.constraint_types +FROM all_tab_columns atc LEFT JOIN constraint_info ci ON atc.owner = ci.owner AND atc.table_name = ci.table_name @@ -271,32 +312,31 @@ LEFT JOIN constraint_info ci WHERE atc.owner = :schema_name AND atc.table_name = :table_name ORDER BY atc.COLUMN_NAME`; - const result = await conn.execute( + + const result = await conn.execute( sql, { table_name: table, schema_name: schema }, { outFormat: oracledb.OUT_FORMAT_OBJECT }, ); - // If schema is not available, throw error. - if (!result.rows || result.rows.length === 0) { + const rows = result.rows ?? []; + if (rows.length === 0) { throw new NodeOperationError(node, 'Schema Information does not exist for selected table', { itemIndex: index, }); } - return ( - result.rows?.map((row: any) => ({ - columnName: row.COLUMN_NAME, - isGenerated: row.IDENTITY_COLUMN === 'YES' ? 'ALWAYS' : 'NEVER', - columnDefault: row.HAS_DEFAULT, - dataType: row.DATA_TYPE, - isNullable: row.NULLABLE === 'Y', - maxSize: row.DATA_LENGTH, - })) ?? [] - ); + return rows.map((row) => ({ + columnName: row.COLUMN_NAME, + isGenerated: isCDBSupported && row.IDENTITY_COLUMN === 'YES' ? 'ALWAYS' : 'NEVER', + columnDefault: row.HAS_DEFAULT, + dataType: row.DATA_TYPE, + isNullable: row.NULLABLE === 'Y', + maxSize: row.DATA_LENGTH, + })); } finally { if (conn) { - await conn.close(); // Ensure connection is closed + await conn.close(); } } } @@ -329,10 +369,20 @@ function normalizeOutBinds( stmtBatching: string, outputColumns: string[], ): Array> { - if (!Array.isArray(outBinds)) return []; - const rows: Array> = []; + if (!Array.isArray(outBinds)) { + // For execute operation mode, we get outBinds as object and + // array for other insert, update, upsert operations. + const row: Record = {}; + for (const [key, val] of Object.entries(outBinds as Record)) { + // If val is expected to be an array, safely extract the first element + row[key] = Array.isArray(val) ? val[0] : val; + } + rows.push(row); + return rows; + } + // executeMany case outBinds-> [ [[col1Row1Val], [col2Row1Val]], [[col1Row2Val], [col2Row2Val]], ...] if (stmtBatching === 'single') { for (const batch of outBinds as any[][]) { @@ -772,6 +822,17 @@ function generateBindVariablesList( return query.replace(regex, generatedSqlString); } +function isSerializedBuffer(val: unknown): val is { type: 'Buffer'; data: number[] } { + return ( + typeof val === 'object' && + val !== null && + 'type' in val && + 'data' in val && + (val as any).type === 'Buffer' && + Array.isArray((val as any).data) + ); +} + export function getBindParameters( query: string, parameterList: ExecuteOpBindParam[], @@ -795,7 +856,24 @@ export function getBindParameters( break; case 'blob': bindVal = item.valueBlob; - break; + + // Allow null or undefined to represent SQL NULL BLOB values + if (bindVal === null) { + break; + } + + if (Buffer.isBuffer(bindVal)) { + break; + } + + // Serialized form: { type: 'Buffer', data: [...] } + if (isSerializedBuffer(bindVal)) { + bindVal = Buffer.from((bindVal as any).data); + break; + } + throw new UserError( + 'BLOB data must be a valid Buffer or \'{ type: "Buffer", data: [...] }\'', + ); case 'date': { const val = item.valueDate; if (typeof val === 'string') { diff --git a/packages/nodes-base/nodes/Oracle/Sql/test/operations.test.ts b/packages/nodes-base/nodes/Oracle/Sql/test/operations.test.ts index 6bfd8f7bff356..9420cebacff7e 100644 --- a/packages/nodes-base/nodes/Oracle/Sql/test/operations.test.ts +++ b/packages/nodes-base/nodes/Oracle/Sql/test/operations.test.ts @@ -19,7 +19,7 @@ import type { OracleDBNodeCredentials, QueryWithValues } from '../helpers/interf import { configureQueryRunner } from '../helpers/utils'; import { configureOracleDB } from '../transport'; -const fakeConnection = { +const mockConnection = { execute: jest.fn((_query = '') => { const result = {} as { rows: any[] }; result.rows = [ @@ -143,6 +143,10 @@ const fakeConnection = { rollback: jest.fn(), }; +Object.defineProperty(mockConnection, 'oracleServerVersion', { + get: () => 2305000000, // mimic server is 23ai +}); + const createFakePool = (connection: IDataObject) => { return { getConnection() { @@ -530,7 +534,7 @@ VALUES ( pool = await configureOracleDB.call(mockThisDef, credentials, {}); await setup(); } else { - pool = createFakePool(fakeConnection); + pool = createFakePool(mockConnection); } }); @@ -906,6 +910,344 @@ VALUES ( expect(expectedArgs[0].query).toMatch(expectedRegex); expect(normalizeParams(expectedArgs[0].values)).toEqual(normalizeParams(expectedVal)); }); + + it('should call runQueries with out binds in plsql using bindInfo and multiple items', async () => { + const expectedQuery = ` + BEGIN + :out_value1 := :in_value * 2; + :out_value2 := :in_value + 10; + :out_value3 := :in_value * :in_value; + END;`; + const makeItem = (i: number) => ({ + json: { in_value: 10 }, + pairedItem: { item: i, input: undefined }, + }); + const items = [makeItem(0), makeItem(1)]; + const outParams = ['out_value1', 'out_value2', 'out_value3']; + const nodeParameters: IDataObject = { + operation: 'execute', + query: expectedQuery, + isBindInfo: true, + resource: 'database', + options: { + params: { + values: [ + { + name: 'in_value', + valueNumber: 10, + datatype: 'number', + parseInStatement: false, + }, + ...outParams.map((name) => ({ + name, + datatype: 'number', + parseInStatement: false, + bindDirection: 'out', + })), + ], + }, + }, + }; + + const mockThis = createMockExecuteFunction(nodeParameters); + const runQueries = getRunQueriesFn(mockThis, pool); + const nodeOptions = nodeParameters.options as IDataObject; + const result = await executeSQL.execute.call(mockThis, runQueries, items, nodeOptions, pool); + + if (integratedTests) { + for (const r of result) { + expect(r.json).toMatchObject({ + out_value1: 20, + out_value2: 20, + out_value3: 100, + }); + } + } + + expect(runQueries).toHaveBeenCalledTimes(1); + + const [calls] = runQueries.mock.calls[0] as [QueryWithValues[], unknown, unknown]; + expect(calls).toHaveLength(2); + + const expectedValues = { + in_value: { type: oracleDBTypes.NUMBER, val: 10, dir: 3002 }, + out_value1: { type: oracleDBTypes.NUMBER, dir: 3003 }, + out_value2: { type: oracleDBTypes.NUMBER, dir: 3003 }, + out_value3: { type: oracleDBTypes.NUMBER, dir: 3003 }, + }; + + calls.forEach((arg) => { + expect(arg.query).toBe(expectedQuery); + expect(arg.values).toEqual(expectedValues); + }); + }); + + it('should call runQueries with binds passed in sql using bindInfo and single item', async () => { + const expectedQuery = `INSERT INTO ${deptTable} (DEPTNO, EMPNAME) VALUES(:DNO, :ENAME) RETURNING EMPNAME INTO :OUTENAME`; + const items = [ + { + json: { + DEPTNO: 100, + EMPNAME: 'ALICE', + }, + pairedItem: { + item: 0, + input: undefined, + }, + }, + ]; + const nodeParameters: IDataObject = { + operation: 'execute', + query: expectedQuery, + isBindInfo: true, + resource: 'database', + options: { + params: { + values: [ + { + name: 'DNO', + valueNumber: 100, + datatype: 'number', + parseInStatement: false, + }, + { + name: 'ENAME', + valueString: 'ALICE', + datatype: 'string', + parseInStatement: false, + }, + { + name: 'OUTENAME', + datatype: 'string', + bindDirection: 'out', + parseInStatement: false, + }, + ], + }, + }, + }; + const mockThis = createMockExecuteFunction(nodeParameters); + const runQueries = getRunQueriesFn(mockThis, pool); + + const nodeOptions = nodeParameters.options as IDataObject; + + const result = await executeSQL.execute.call(mockThis, runQueries, items, nodeOptions, pool); + if (integratedTests) { + for (const r of result) { + expect(r.json).toMatchObject({ + DNO: 100, + ENAME: 'ALICE', + OUTENAME: 'ALICE', + }); + } + } + + // Assert that runQueries was called with expected query and bind values + expect(runQueries).toHaveBeenCalledTimes(1); + const callArgs = runQueries.mock.calls[0] as [QueryWithValues[], unknown, unknown]; + const [expectedArgs] = callArgs; + const val = { + DNO: { + type: oracleDBTypes.NUMBER, + val: 100, + dir: 3002, + }, + ENAME: { + type: oracleDBTypes.DB_TYPE_VARCHAR, + val: 'ALICE', + dir: 3002, + }, + OUTENAME: { + type: oracleDBTypes.STRING, + dir: 3003, + val: undefined, + }, + }; + + expect(expectedArgs).toHaveLength(1); + expect(expectedArgs[0].query).toBe(expectedQuery); + expect(expectedArgs[0].values).toEqual(val); + }); + + it('should bind NULL, valid, and mixed values correctly for all supported column types', async () => { + const expectedQuery = `INSERT INTO ${table} ( + id, name, age, salary, join_date, is_active, meta_data, embedding, picture) VALUES ( + :ID, :NAME, :AGE, :SALARY, :JOIN_DATE, :IS_ACTIVE, :META_DATA, :EMBEDDING, :PICTURE)`; + + const DOJ = '2024-01-15T00:00:00.000Z'; + + const inputs = [ + { + label: 'non-null values', + expectedId: 1, + items: [ + { + json: { + ID: 1, + NAME: 'Alice', + AGE: 30, + SALARY: 75000.5, + JOIN_DATE: DOJ, + IS_ACTIVE: true, + META_DATA: { team: 'AI', level: 5 }, + EMBEDDING: [0.1, 0.2, 0.3], + PICTURE: Buffer.from('hello world'), + }, + }, + ], + params: [ + { name: 'ID', valueNumber: 1, datatype: 'number' }, + { name: 'NAME', valueString: 'Alice', datatype: 'string' }, + { name: 'AGE', valueNumber: 30, datatype: 'number' }, + { name: 'SALARY', valueNumber: 75000.5, datatype: 'number' }, + { name: 'JOIN_DATE', valueDate: DOJ, datatype: 'date' }, + { name: 'IS_ACTIVE', valueBoolean: true, datatype: 'boolean' }, + { name: 'META_DATA', valueJson: { team: 'AI', level: 5 }, datatype: 'json' }, + { name: 'EMBEDDING', valueVector: [0.1, 0.2, 0.3], datatype: 'vector' }, + { name: 'PICTURE', valueBlob: Buffer.from('hello world'), datatype: 'blob' }, + ], + }, + { + label: 'null values', + expectedId: 2, + items: [ + { + json: { + ID: 2, + NAME: null, + AGE: null, + SALARY: null, + JOIN_DATE: null, + IS_ACTIVE: null, + META_DATA: null, + EMBEDDING: null, + PICTURE: null, + }, + }, + ], + params: [ + { name: 'ID', valueNumber: 2, datatype: 'number' }, + { name: 'NAME', valueString: null, datatype: 'string' }, + { name: 'AGE', valueNumber: null, datatype: 'number' }, + { name: 'SALARY', valueNumber: null, datatype: 'number' }, + { name: 'JOIN_DATE', valueDate: null, datatype: 'date' }, + { name: 'IS_ACTIVE', valueBoolean: null, datatype: 'boolean' }, + { name: 'META_DATA', valueJson: null, datatype: 'json' }, + { name: 'EMBEDDING', valueVector: null, datatype: 'vector' }, + { name: 'PICTURE', valueBlob: null, datatype: 'blob' }, + ], + }, + { + label: 'mixed values', + expectedId: 3, + items: [ + { + json: { + ID: 3, + NAME: null, // mix of null + valid + AGE: 45, + SALARY: null, + JOIN_DATE: DOJ, + IS_ACTIVE: false, + META_DATA: null, + EMBEDDING: [0.5, 0.6, 3.4], + PICTURE: { type: 'Buffer', data: [14, 15, 16] }, + }, + }, + ], + params: [ + { name: 'ID', valueNumber: 3, datatype: 'number' }, + { name: 'NAME', valueString: null, datatype: 'string' }, + { name: 'AGE', valueNumber: 45, datatype: 'number' }, + { name: 'SALARY', valueNumber: null, datatype: 'number' }, + { name: 'JOIN_DATE', valueDate: DOJ, datatype: 'date' }, + { name: 'IS_ACTIVE', valueBoolean: false, datatype: 'boolean' }, + { name: 'META_DATA', valueJson: null, datatype: 'json' }, + { name: 'EMBEDDING', valueVector: [0.5, 0.6, 3.4], datatype: 'vector' }, + { + name: 'PICTURE', + valueBlob: { type: 'Buffer', data: [14, 15, 16] }, + datatype: 'blob', + }, + ], + }, + ]; + + for (const input of inputs) { + const nodeParameters: IDataObject = { + operation: 'execute', + query: expectedQuery, + isBindInfo: true, + resource: 'database', + options: { + params: { + values: input.params, + }, + }, + }; + + const mockThis = createMockExecuteFunction(nodeParameters); + const runQueries = getRunQueriesFn(mockThis, pool); + const nodeOptions = nodeParameters.options as IDataObject; + + const result = await executeSQL.execute.call( + mockThis, + runQueries, + input.items, + nodeOptions, + pool, + ); + + expect(runQueries).toHaveBeenCalled(); + + const [expectedArgs] = runQueries.mock.calls.pop() as [QueryWithValues[], unknown, unknown]; + expect(expectedArgs).toHaveLength(1); + expect(expectedArgs[0].query).toBe(expectedQuery); + + const binds = expectedArgs[0].values; + + // Dynamic assertions per input type + for (const [key, bind] of Object.entries(binds as Record)) { + if (key === 'ID') { + expect(bind.val).toBe(input.expectedId); + } else { + const original = (input.items[0].json as Record)[key]; + if (original === null || original === undefined) { + expect(bind.val).toBeNull(); + } else if (key === 'JOIN_DATE') { + expect(bind.val instanceof Date).toBe(true); + expect((bind.val as Date).toISOString()).toBe(DOJ); + } else if (key === 'PICTURE' && original.type === 'Buffer') { + // serialized form + expect(bind.val).toEqual(Buffer.from(original)); + } else { + expect(bind.val).toEqual(original); + } + } + } + + // Optional integrated check + if (integratedTests) { + const row = result[0].json; + expect(row.ID).toBe(input.expectedId); + for (const [key, val] of Object.entries(row)) { + if (key === 'ID' || key === 'PICTURE') continue; + const original = (input.items[0].json as Record)[key]; + if (original === null || original === undefined) { + expect(val === null || val === undefined).toBe(true); + } else if (key === 'JOIN_DATE') { + expect((val as Date).toISOString()).toBe(DOJ); + } else if (key === 'META_DATA') { + continue; + } else if (key === 'EMBEDDING') { + expect(Array.from(val as Float64Array)).toEqual(original); + } else { + expect(val).toEqual(original); + } + } + } + } + }); }); describe('Test select operation', () => { diff --git a/packages/nodes-base/nodes/Oracle/Sql/transport/index.ts b/packages/nodes-base/nodes/Oracle/Sql/transport/index.ts index 06f5173f9a082..24584ead9b112 100644 --- a/packages/nodes-base/nodes/Oracle/Sql/transport/index.ts +++ b/packages/nodes-base/nodes/Oracle/Sql/transport/index.ts @@ -14,7 +14,11 @@ import type { OracleDBNodeOptions, OracleDBNodeCredentials } from '../helpers/in let initializeDriverMode = false; const getOracleDBConfig = (credentials: OracleDBNodeCredentials) => { - const { useThickMode, useSSL, ...dbConfig } = credentials; + const { useThickMode, useSSL, ...dbConfig } = { + ...credentials, + privilege: credentials.privilege || undefined, + }; + return dbConfig; };