From 3461d7065920c83c7684f45b00b5d301b7834ecd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:57:11 +0000 Subject: [PATCH 1/3] Add SQL schema automation to install.js controller Co-authored-by: jniles <896472+jniles@users.noreply.github.com> --- server/controllers/install.js | 250 ++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/server/controllers/install.js b/server/controllers/install.js index b3d3d1eba0..052ad44cf4 100644 --- a/server/controllers/install.js +++ b/server/controllers/install.js @@ -7,8 +7,13 @@ * * @requires lib/db * @requires lib/errors/BadRequest + * @requires fs/promises + * @requires path */ +const debug = require('debug')('install'); +const fs = require('fs/promises'); +const path = require('path'); const db = require('../lib/db'); const BadRequest = require('../lib/errors/BadRequest'); @@ -25,6 +30,245 @@ const DEFAULTS = { settings : { enterprise_id : 1 }, }; +/** + * @const SQL_MODELS_PATH + * + * @description + * Path to the SQL models directory + */ +const SQL_MODELS_PATH = path.join(__dirname, '../models'); + +/** + * @function getSQLFiles + * + * @description + * Discovers all SQL files in the server/models/ directory and sorts them + * in ascending order by filename. + * + * @returns {Promise} Array of sorted SQL file paths + */ +async function getSQLFiles() { + try { + const files = await fs.readdir(SQL_MODELS_PATH); + + // Filter for .sql files and sort them + const sqlFiles = files + .filter(file => file.endsWith('.sql')) + .sort(); + + debug(`Found ${sqlFiles.length} SQL files:`, sqlFiles); + + return sqlFiles.map(file => path.join(SQL_MODELS_PATH, file)); + } catch (error) { + debug('Error reading SQL files:', error); + throw error; + } +} + +/** + * @function checkSchemaExists + * + * @description + * Checks if the database schema already exists by checking for critical tables. + * + * @returns {Promise} true if schema exists, false otherwise + */ +async function checkSchemaExists() { + try { + // Check if critical tables exist + const query = ` + SELECT COUNT(*) as count + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ('user', 'enterprise', 'project', 'account') + `; + + const result = await db.one(query); + const schemaExists = result.count >= 4; + + debug(`Schema exists check: ${schemaExists} (found ${result.count} critical tables)`); + + return schemaExists; + } catch (error) { + debug('Error checking schema existence:', error); + // If we can't query, assume schema doesn't exist + return false; + } +} + +/** + * @function executeSQLFile + * + * @description + * Executes a single SQL file by reading its contents and executing it as a whole. + * This handles complex SQL files with DELIMITER statements, stored procedures, + * functions, and triggers properly. + * + * @param {String} filePath - Path to the SQL file + * @returns {Promise} Resolves when execution is complete + */ +async function executeSQLFile(filePath) { + try { + const fileName = path.basename(filePath); + debug(`Executing SQL file: ${fileName}`); + + const sqlContent = await fs.readFile(filePath, 'utf8'); + + if (!sqlContent.trim()) { + debug(`Skipping empty file: ${fileName}`); + return; + } + + // Get a connection from the pool with multipleStatements enabled + const connection = await db.pool.getConnection(); + + try { + // Parse the SQL file to handle DELIMITER commands + const processedSQL = processSQLWithDelimiters(sqlContent); + + // Execute all statements sequentially + // eslint-disable-next-line no-restricted-syntax + for (const statement of processedSQL) { + if (statement.trim()) { + try { + // eslint-disable-next-line no-await-in-loop + await connection.query(statement); + } catch (error) { + // Log the error but continue with other statements for idempotency + // Only ignore errors related to objects that already exist + const ignorableErrors = [ + 'ER_TABLE_EXISTS_ERROR', + 'ER_DUP_KEYNAME', + 'ER_SP_ALREADY_EXISTS', + 'ER_DB_CREATE_EXISTS', + ]; + + if (!ignorableErrors.includes(error.code)) { + debug(`Error executing statement in ${fileName}:`, error.message); + // Re-throw non-ignorable errors + throw error; + } + debug(`Ignoring duplicate object error in ${fileName}: ${error.code}`); + } + } + } + + debug(`Successfully executed SQL file: ${fileName}`); + } finally { + connection.release(); + } + } catch (error) { + debug(`Error executing SQL file ${filePath}:`, error); + throw error; + } +} + +/** + * @function processSQLWithDelimiters + * + * @description + * Processes SQL content that may contain DELIMITER commands, splitting + * the content into executable statements based on the active delimiter. + * + * @param {String} sqlContent - The SQL file content + * @returns {Array} Array of SQL statements ready to execute + */ +function processSQLWithDelimiters(sqlContent) { + const statements = []; + let currentDelimiter = ';'; + let buffer = ''; + + // Split by newlines to process line by line + const lines = sqlContent.split('\n'); + + // eslint-disable-next-line no-restricted-syntax + for (const line of lines) { + const trimmedLine = line.trim(); + + // Check for DELIMITER command + if (trimmedLine.match(/^DELIMITER\s+(.+)$/i)) { + // Save any buffered content with the old delimiter + if (buffer.trim()) { + statements.push(buffer.trim()); + buffer = ''; + } + + // Update the delimiter + const match = trimmedLine.match(/^DELIMITER\s+(.+)$/i); + currentDelimiter = match[1].trim(); + // eslint-disable-next-line no-continue + continue; + } + + // Add line to buffer + buffer += `${line}\n`; + + // Check if we've hit the current delimiter + if (trimmedLine.endsWith(currentDelimiter)) { + // Remove the delimiter from the end + const statement = buffer.substring(0, buffer.lastIndexOf(currentDelimiter)).trim(); + if (statement) { + statements.push(statement); + } + buffer = ''; + } + } + + // Add any remaining content + if (buffer.trim()) { + statements.push(buffer.trim()); + } + + // Filter out comments and empty statements + return statements.filter(stmt => { + const trimmed = stmt.trim(); + return trimmed && !trimmed.startsWith('--') && !trimmed.startsWith('/*'); + }); +} + +/** + * @function setupDatabaseSchema + * + * @description + * Sets up the database schema by executing all SQL files in the server/models/ + * directory. This function is idempotent and will not fail if objects already exist. + * + * @returns {Promise} Resolves when schema setup is complete + */ +async function setupDatabaseSchema() { + try { + debug('Starting database schema setup'); + + // Check if schema already exists + const schemaExists = await checkSchemaExists(); + + if (schemaExists) { + debug('Database schema already exists, skipping setup'); + return; + } + + // Get all SQL files sorted by filename + const sqlFiles = await getSQLFiles(); + + if (sqlFiles.length === 0) { + debug('No SQL files found in models directory'); + return; + } + + // Execute each SQL file in order sequentially + // eslint-disable-next-line no-restricted-syntax + for (const filePath of sqlFiles) { + // eslint-disable-next-line no-await-in-loop + await executeSQLFile(filePath); + } + + debug('Database schema setup completed successfully'); + } catch (error) { + debug('Error setting up database schema:', error); + throw error; + } +} + /** * @method basicInstallExist * @@ -88,6 +332,9 @@ exports.proceedInstall = async (req, res) => { throw new BadRequest('The application is already installed'); } + // Setup database schema if it doesn't exist + await setupDatabaseSchema(); + const location = await defaultEnterpriseLocation(); await createEnterpriseProjectUser(enterprise, project, user, location.uuid); res.redirect('/'); @@ -128,3 +375,6 @@ function createEnterpriseProjectUser(enterprise, project, user, locationUuid) { .addQuery(sqlRole) .execute(); } + +// Export schema setup function for external use +exports.setupDatabaseSchema = setupDatabaseSchema; From 2dd787cfa096a4ca261fbe4d4793411b02586f4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:59:58 +0000 Subject: [PATCH 2/3] Add unit tests for install controller Co-authored-by: jniles <896472+jniles@users.noreply.github.com> --- test/server-unit/install.spec.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/server-unit/install.spec.js diff --git a/test/server-unit/install.spec.js b/test/server-unit/install.spec.js new file mode 100644 index 0000000000..d344ca756f --- /dev/null +++ b/test/server-unit/install.spec.js @@ -0,0 +1,27 @@ +/* eslint global-require:off */ +const { expect } = require('chai'); +const path = require('path'); + +describe('test/server-unit/install', () => { + + let install; + before(() => { + install = require('../../server/controllers/install'); + }); + + it('should export setupDatabaseSchema function', () => { + expect(install.setupDatabaseSchema).to.be.a('function'); + }); + + it('should export checkBasicInstallExist function', () => { + expect(install.checkBasicInstallExist).to.be.a('function'); + }); + + it('should export proceedInstall function', () => { + expect(install.proceedInstall).to.be.a('function'); + }); + + // Additional integration test would verify that setupDatabaseSchema + // can properly read and execute SQL files from server/models/ + // This requires a database connection and is better suited for integration tests +}); From 4439f79a37dc3ac1bd2bf2237e5b91d8bb322da7 Mon Sep 17 00:00:00 2001 From: Jonathan Niles Date: Tue, 28 Oct 2025 16:35:34 -0500 Subject: [PATCH 3/3] chore: remove unused path --- test/server-unit/install.spec.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/server-unit/install.spec.js b/test/server-unit/install.spec.js index d344ca756f..1987f5d594 100644 --- a/test/server-unit/install.spec.js +++ b/test/server-unit/install.spec.js @@ -1,6 +1,5 @@ /* eslint global-require:off */ const { expect } = require('chai'); -const path = require('path'); describe('test/server-unit/install', () => { @@ -20,8 +19,4 @@ describe('test/server-unit/install', () => { it('should export proceedInstall function', () => { expect(install.proceedInstall).to.be.a('function'); }); - - // Additional integration test would verify that setupDatabaseSchema - // can properly read and execute SQL files from server/models/ - // This requires a database connection and is better suited for integration tests });