diff --git a/.bxlint.json b/.bxlint.json new file mode 100644 index 0000000..e5d1d43 --- /dev/null +++ b/.bxlint.json @@ -0,0 +1,68 @@ +{ + "diagnostics": { + "duplicateMethod": { + "enabled": true, + "severity": "error" + }, + "duplicateProperty": { + "enabled": true, + "severity": "error" + }, + "emptyCatchBlock": { + "enabled": true, + "severity": "warning" + }, + "invalidExtends": { + "enabled": false, + "severity": "error" + }, + "invalidImplements": { + "enabled": false, + "severity": "error" + }, + "missingQueryParamCfsqltype": { + "enabled": true, + "severity": "warning" + }, + "missingReturnStatement": { + "enabled": true, + "severity": "warning" + }, + "shadowedVariable": { + "enabled": true, + "severity": "warning" + }, + "unescapedQueryParam": { + "enabled": true, + "severity": "warning" + }, + "unreachableCode": { + "enabled": true, + "severity": "warning" + }, + "unscopedVariable": { + "enabled": true, + "severity": "warning" + }, + "unusedImport": { + "enabled": true, + "severity": "warning" + }, + "unusedPrivateMethod": { + "enabled": true, + "severity": "info" + }, + "unusedVariable": { + "enabled": true, + "severity": "hint" + } + }, + "include": [], + "exclude": [], + "mappings": {}, + "formatting": { + "experimental": { + "enabled": false + } + } +} \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.MD b/.github/CODE_OF_CONDUCT.MD new file mode 100644 index 0000000..12507ab --- /dev/null +++ b/.github/CODE_OF_CONDUCT.MD @@ -0,0 +1,3 @@ +# Code of Conduct + +Please see it in our [Contributing Guidelines](../CONTRIBUTING.md#code-of-conduct). diff --git a/.github/FUNDING.YML b/.github/FUNDING.YML new file mode 100644 index 0000000..7e59d13 --- /dev/null +++ b/.github/FUNDING.YML @@ -0,0 +1 @@ +patreon: ortussolutions diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md new file mode 100644 index 0000000..300232e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +--- + + + +## What are the steps to reproduce this issue? + +1. … +2. … +3. … + +## What happens? + +… + +## What were you expecting to happen? + +… + +## Any logs, error output, etc? + +… + +## Any other comments? + +… + +## What versions are you using? + +**Operating System:** … +**Package Version:** … diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md new file mode 100644 index 0000000..c10946f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: Request a new feature or enhancement +--- + + + +## Summary + + + +## Detailed Description + + + +## Possible Implementation Ideas + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e8bd9f9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +# Description + +Please include a summary of the changes and which issue(s) is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +**Please note that all PRs must have tests attached to them** + +IMPORTANT: Please review the [CONTRIBUTING.md](../CONTRIBUTING.md) file for detailed contributing guidelines. + +## Issues + +All PRs must have an accompanied issue. Please make sure you created it and linked it here. + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug Fix +- [ ] Improvement +- [ ] New Feature +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +## Checklist + +- [ ] My code follows the style guidelines of this project [cfformat](../.cfformat.json) +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..f057099 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Please see it in our [Contributing Guidelines](../CONTRIBUTING.md#security-vulnerabilities). diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..3bb8adb --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,3 @@ +# Support & Help + +Please see it in our [Contributing Guidelines](../CONTRIBUTING.md#support-questions). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e4043a8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # GitHub Actions - updates uses: statements in workflows + - package-ecosystem: "github-actions" + directory: "/" # Where your .github/workflows/ folder is + schedule: + interval: "quarterly" + + # Gradle - updates dependencies in build.gradle or build.gradle.kts + - package-ecosystem: "gradle" + directory: "/" # Adjust if build.gradle is in a subfolder + schedule: + interval: "quarterly" + + # NPM + - package-ecosystem: "npm" + directory: "/" # adjust if needed + schedule: + interval: "quarterly" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..76afdb6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: + - master + - development + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: TestBox Suite + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup CommandBox + uses: ortus-boxlang/setup-boxlang@main + with: + with-commandbox: true + + - name: Install dependencies + run: box install + + - name: Run tests + run: box run-script test diff --git a/.gitignore b/.gitignore index ea99fc0..ad94da0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ /modules/ +/testbox/ +.test-tmp/ TASKS.md jmimemagic.log -.vscode \ No newline at end of file +.vscode + +## AI +.opencode/** +.agents/skills/** \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..9f231b5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,15 @@ +{ + "line-length": false, + "single-h1": false, + "no-hard-tabs" : false, + "fenced-code-language" : false, + "no-bare-urls" : false, + "first-line-h1": false, + "no-multiple-blanks": { + "maximum": 2 + }, + "no-duplicate-header" : false, + "no-duplicate-heading" : false, + "no-inline-html" : false, + "no-emphasis-as-heading": false +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..99d7297 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,177 @@ +# Project Guidelines + +## Code Style + +This is a **CommandBox module** written in CFML (`.cfc`) with BoxLang (`.bx`) templates for scaffolding. Follow the **Ortus Coding Standards** skill (`ortus-coding-standards`) for all formatting — the key rules are: + +- **Tabs** for indentation, never spaces. +- **K&R brace style** — opening brace on the same line as the statement. +- **Always use braces** — even for single-statement `if`/`for`/`while`. +- **Spaces inside parentheses** — `function process( name, count )` not `function process(name, count)`. +- **No space between function name and `(`** — `doThing( name )` not `doThing ( name )`. +- **One space around operators** — `var total = price * quantity`. +- **Align related assignments** in column groups. +- **Always use braces** — even for single-statement bodies, never inline bodies on the same line as the condition. + +Semicolons are **optional** in CFML/BoxLang — match the surrounding file's convention (most files omit them). + +Arrow functions use the fat-arrow syntax: `( migration ) => { ... }` + +CFML code is formatted via **CFFormat** (`.cfformat.json`). After editing `.cfc` files, run: + +``` +box run-script format +``` + +BoxLang templates (`.bx`) are linted via `.bxlint.json`. + +## Architecture + +This is a **CommandBox CLI module** that wraps [cfmigrations](https://github.com/coldbox-modules/cfmigrations) to provide `migrate` commands. Key structure: + +- `commands/migrate/` — CommandBox commands (each `.cfc` has a `run()` method). Nested folders are sub-commands (e.g., `seed/run.cfc` → `migrate seed run`). +- `models/BaseMigrationCommand.cfc` — Shared base class all commands extend. Contains `setup()`, `getMigrationsInfo()`, `isBoxLangProject()`, config resolution, and datasource registration. +- `ModuleConfig.cfc` — WireBox module lifecycle; registers `SqlHighlighter` singleton. +- `templates/` — Scaffolding templates for new migrations/seeds. `.txt` = CFML templates, `BX.txt` suffix = BoxLang templates. +- `lib/sql.nanorc` — jLine syntax highlighting rules for SQL output in CLI. + +**Dependency Injection**: WireBox via `property name="x" inject="Y"` annotations. Key services: `FileSystem`, `PackageService`, `JSONService`, `SystemSettings`, `ServerService`, `Formatter@sqlFormatter`, `SqlHighlighter`. + +**Config resolution order**: `.cbmigrations.json` → legacy `.cfmigrations.json` → legacy `box.json` `cfmigrations` key (deprecated, auto-converted). Environment variables are expanded via `systemSettings.expandDeepSystemSettings()`. + +**Dual-language support**: Auto-detects BoxLang via running server engine or `box.json` `language` key. When generating scaffolding, select the correct template pair (`Migration.txt` vs `MigrationBX.txt`). + +## Build and Test + +```bash +# Format CFML files +box run-script format + +# No test suite exists — this project relies on manual/CLI testing. +``` + +The only automated script is `format` (CFFormat). Release flow: format → publish to ForgeBox → git push with tags. + +## Conventions + +### Copyright Header + +Every `.cfc` and `.bx` file starts with: + +``` +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + */ +``` + +### Command Structure + +All commands follow this pattern: + +1. `extends="commandbox-migrations.models.BaseMigrationCommand"` +2. JavaDoc-style `/** */` parameter annotations with `@paramName.optionsUDF completeXxx` for tab-completion wiring +3. `run()` method calls `setup( arguments.manager )` first +4. Optional verbose diagnostics block +5. `pagePoolClear()` before migration execution +6. `try`/`catch` with SQL formatting and highlighting in error output +7. Pre/post process hooks via lambdas for migration logging and SQL capture +8. `pretend` mode captures SQL without executing + +### Error Handling Pattern + +```js +catch ( any e ) { + if ( arguments.verbose ) { + if ( structKeyExists( e, "Sql" ) ) { + print.whiteOnRedLine( "Error when trying to ..." ); + print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); + } + rethrow; + } + return error( e.message, e.detail ); +} +``` + +### Naming + +| Item | Convention | Example | +|------|-----------|---------| +| Command CFCs | Lowercase verb | `up.cfc`, `down.cfc`, `fresh.cfc` | +| Models | PascalCase | `BaseMigrationCommand.cfc` | +| Templates | PascalCase, `BX` suffix | `Migration.txt` / `MigrationBX.txt` | +| Config files | Dot-prefixed JSON | `.cbmigrations.json`, legacy `.cfmigrations.json` | + +## Skills + +Project-specific agent skills are located in `.agents/skills/`. Each skill has a `SKILL.md` file with detailed instructions. When working on a task that matches a skill's domain, read the skill file first. + +### BoxLang Language + +| Skill | Description | +|-------|-------------| +| `boxlang-application-descriptor` | Application.bx behavior: app discovery, lifecycle events, sessions, mappings, schedulers/watchers | +| `boxlang-async-programming` | BoxFuture, asyncRun, asyncAll, executors, schedulers, thread components, parallel pipelines | +| `boxlang-best-practices` | Community best practices for naming, structure, scoping, error handling, performance | +| `boxlang-cfml-migration` | Migrating from CFML (Adobe/Lucee) to BoxLang: syntax differences, bx-compat-cfml, common issues | +| `boxlang-classes-and-oop` | Classes, components, interfaces, inheritance, annotations, properties, constructors, OOP patterns | +| `boxlang-code-documenter` | Javadoc-style comments, argument/return documentation, DocBox-compatible API reference generation | +| `boxlang-code-reviewer` | Code review for quality, correctness, security, performance, and style | +| `boxlang-configuration` | boxlang.json settings, env var overrides, datasources, caches, executors, modules, logging | +| `boxlang-database-access` | queryExecute, bx:query, datasource config, parameterized queries, transactions, SQL injection prevention | +| `boxlang-docbox` | DocBox API documentation generation: install, CLI, config, output strategies, themes | +| `boxlang-file-handling` | fileRead, fileWrite, fileCopy, fileMove, directoryList, fileUpload, streaming, CSV/JSON processing | +| `boxlang-file-watchers` | Filesystem watchers: watcherNew/Start/Stop, event payloads, debounce/throttle, error thresholds | +| `boxlang-functional-programming` | Lambdas, closures, arrow functions, array/struct pipelines (map, filter, reduce), destructuring, spread | +| `boxlang-interceptors` | Interceptor/event system: registration, announcement points, pre/post hooks, BoxRegisterInterceptor | +| `boxlang-java-integration` | createObject, static methods, type conversion, importing classes, closures as functional interfaces, JARs | +| `boxlang-language-fundamentals` | Syntax, file types, variables, scopes, operators, control flow, exception handling, type system | +| `boxlang-modules-and-packages` | box install, module settings, BoxLang+ premium modules (bx-pdf, bx-redis, bx-csv), ORM, mail | +| `boxlang-runtime-cli-scripting` | CLI scripts, command-line arguments, REPL, action commands (compile, cftranspile), CLI-specific BIFs | +| `boxlang-runtime-commandbox` | Deploying via CommandBox: server.json, modules, SSL, rewrites, BoxLang+/++ subscriptions | +| `boxlang-scheduled-tasks` | Scheduler DSL, BaseScheduler/ScheduledTask APIs, cron expressions, lifecycle callbacks, bx:schedule | +| `boxlang-security` | Security review, OWASP Top 10, injection prevention, file upload safety, secrets management | +| `boxlang-templating` | .bxm templates, bx:output, bx:loop, bx:if, bx:include, bx:script, building views | +| `boxlang-testing` | TestBox: BDD specs, xUnit classes, expectations, MockBox, mockData, async testing, CLI runner | +| `boxlang-web-development` | Web apps: request/response, sessions, forms, REST APIs, HTTP clients, routing, CSRF, SSE | +| `boxlang-zip` | bx:zip component: compress, extract, filter entries, read archives, download as ZIP | + +### CommandBox CLI + +| Skill | Description | +|-------|-------------| +| `commandbox-config-settings` | Global config: set/show/clear, server defaults, ForgeBox tokens, endpoints, proxy, env overrides | +| `commandbox-deploying` | Production deployment: Docker, GitHub Actions, Heroku, Lightsail, OS service, server.json, CFConfig | +| `commandbox-developing` | Custom commands, modules, namespaces, tab completion, WireBox DI, interceptors, lifecycle events | +| `commandbox-embedded-server` | Server management: start/stop, server.json, JVM args, SSL/TLS, rewrites, rules, profiles, auth, gzip | +| `commandbox-package-management` | box.json, installing from ForgeBox/Git/HTTP, semver, dependencies, lock files, publishing | +| `commandbox-setup` | Installing/upgrading CommandBox: Homebrew, apt-get, Windows, Java requirements, first-run config | +| `commandbox-task-runners` | Task CFCs, targets, lifecycle events, interactive jobs, progress bars, async, file watching | +| `commandbox-testing` | testbox run command, runner URL, output formats, test watcher, CI integration, code coverage | +| `commandbox-usage` | CLI usage: commands, namespaces, tab completion, system settings, env vars, piping, recipes, REPL | + +### TestBox Testing + +| Skill | Description | +|-------|-------------| +| `testbox-assertions` | $assert object: isTrue, isEqual, includes, throws, between, closeTo, typeOf, custom assertions | +| `testbox-bdd` | BDD describe/it blocks, Gherkin-style suites, lifecycle hooks, focused/skipped specs, asyncAll | +| `testbox-cbmockdata` | Fake data generation: age, email, name, uuid, autoincrement, nested objects, custom suppliers | +| `testbox-expectations` | Fluent expect() matchers: toBe, toBeTrue, toHaveKey, toThrow, notToBe, chaining, custom matchers | +| `testbox-listeners` | Run listeners: onBundleStart/End, onSuiteStart/End, onSpecStart/End, progress, dashboards | +| `testbox-mockbox` | Mocks/stubs/spies: createMock, prepareMock, $args/$results/$throws, $callLog, $property, querySim | +| `testbox-reporters` | Reporters: ANTJunit, Console, Doc, JSON, JUnit, Min, Simple, XML, Streaming, custom IReporter | +| `testbox-runners` | Running tests: CLI, BoxLang CLI runner, HTML web runner, programmatic, streaming, watcher mode | +| `testbox-unit-xunit` | xUnit-style: testXxx() functions, setup/teardown lifecycle, $assert, Arrange-Act-Assert pattern | +| `testing-coverage` | Code coverage: setup, reporting, CI integration, TestBox options, interpreting metrics | +| `testing-fixtures` | Test fixtures, factory patterns, test data builders, cbMockData, fixture management | + +### Other + +| Skill | Description | +|-------|-------------| +| `ortus-coding-standards` | Official Ortus coding standards: indentation, spacing, braces, naming, alignment, comments | +| `github-action-authoring` | Composite GitHub Actions: multi-platform, PATH issues, inputs/outputs, PowerShell, CI testing | +| `java-expert` | Java services/libraries: API design, concurrency, performance, dependency management, production | +| `junit-expert` | JUnit 5 tests: lifecycle, parameterized tests, extensions, assertions, parallel execution, suites | diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 1bc51fb..bdcc911 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -1,7 +1,16 @@ -component { +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + */ + component { this.dependencies = [ "cfmigrations", "sqlFormatter" ]; + /** + * Module lifecycle method. Registers the SqlHighlighter singleton, falling back + * to a no-op highlighter if the jLine SyntaxHighlighter can't be built. + */ function configure() { var sqlHighlighter = { "highlight": ( str ) => ( { @@ -27,4 +36,36 @@ component { .toValue( sqlHighlighter ); } + /** + * Fired when the module is registered and activated. When the active + * runtime is BoxLang, eagerly loads any JDBC driver modules already + * present in `/boxlang_modules/` so migrations can be + * executed without a second restart. + * + * Loading is a no-op when: + * - the runtime is not BoxLang + * - the `boxlang_modules` directory does not yet exist + * - the BoxRuntime singleton cannot be reached + * + * `loadModules(Path)` is idempotent — already-registered modules are + * skipped — so it is safe to call from both this hook and the + * `BaseMigrationCommand` install path. + */ + public void function onLoad() { + if ( !structKeyExists( server, "boxlang" ) ) { + return + } + + var modulesDir = variables.modulePath & "/boxlang_modules"; + + if ( !directoryExists( modulesDir ) ) { + return + } + + var paths = createObject( "java", "java.nio.file.Paths" ); + getBoxRuntime() + .getModuleService() + .loadModules( paths.get( modulesDir ) ) + } + } diff --git a/README.md b/README.md index a711ad7..bec4be2 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,58 @@ -# `commandbox-migrations` +# CommandBox Migrations -## Run your [`cfmigrations`](https://github.com/elpete/cfmigrations) from CommandBox +CommandBox Migrations is a [CommandBox](https://www.ortussolutions.com/products/commandbox) module that lets you run database migrations directly from the CLI. It wraps the [ColdBox Migrations](https://github.com/coldbox-modules/cfmigrations) library, giving you a powerful, convention-driven way to evolve your database schema — without needing a running web server. +## Features + +- **CLI-driven** — Run migrations from anywhere using `migrate up`, `migrate down`, `migrate status`, `migrate fresh`, and more. +- **Multi-manager support** — Manage multiple named migration configurations (e.g., `default`, `alternate`) from a single project. +- **Seeding** — Create and run database seeds with `migrate seed create` and `migrate seed run`. +- **BoxLang support** — Full support for BoxLang projects with automatic detection and `.cbmigrations.json` config. +- **Environment-aware** — Leverage `${ENV_VAR}` placeholders in your config for database credentials and settings. +- **Init scaffolding** — Get up and running fast with `migrate init` to generate your config and first migration. + +## Upgrading to v6.0.0? + +v6 makes `.cbmigrations.json` the **universal standard** config file for **all** projects — BoxLang and CFML alike. The legacy `.cfmigrations.json` is fully deprecated. + +### What changed + +- **Config file:** `.cbmigrations.json` is now the one and only config file name. If `migrate init` finds only `.cfmigrations.json`, it will prompt you to rename it automatically. If both files exist, `.cbmigrations.json` takes priority. +- **Migration table:** The default migration table name is now `cbmigrations` (was `cfmigrations` in v4/v5). New projects created with `migrate init` will use `cbmigrations` by default. +- **BoxLang support:** All the BoxLang support introduced in v5 is now fully mature. The `--boxlang` / `--no-boxlang` flags work identically, but `.cbmigrations.json` is used for both BoxLang and CFML projects alike. + +### How to upgrade + +1. Rename your `.cfmigrations.json` to `.cbmigrations.json` (or let `migrate init` do it for you). +2. If you are starting fresh, your migration table will default to `cbmigrations`. If you have an existing `cfmigrations` table, keep the `"migrationsTable": "cfmigrations"` setting in your config. + +## Upgrading to v5.0.0? + +v5 introduced first-class [BoxLang](https://boxlang.io) support alongside the CFML experience. + +### What changed + +- **BoxLang detection:** The module now auto-detects BoxLang projects based on the running server engine or the `"language": "boxlang"` key in `box.json`. +- **Dual config support:** `.cbmigrations.json` was introduced as the BoxLang config file. If both `.cbmigrations.json` and `.cfmigrations.json` exist, the `.cbmigrations.json` file is read first. +- **Scaffolding:** `migrate create` and `migrate seed create` generate `.bx` files for BoxLang projects (auto-detected, or overridden with `--boxlang` / `--no-boxlang`). +- **`migrate init --boxlang`:** The init command gained `--boxlang` / `--no-boxlang` flags to override auto-detection. + +### How to upgrade + +Upgrading from v4 is straightforward — no breaking config changes. If you're a BoxLang user, your project will be auto-detected and you'll get `.bx` scaffolding automatically. If you're a CFML user, nothing changes. ## Upgrading to v4.0.0? +> ⚠️ **Legacy:** v4 introduced the `.cfmigrations.json` config file. As of v6, the standard config file is `.cbmigrations.json` for all projects. See [Upgrading to v6.0.0?](#upgrading-to-v600) for details. + v4 brings a new configuration structure and file. This pairs with new features in CFMigrations to allow for multiple named migration managers and new seeding capabilities. Migrations will still run in v4 using the old configuration structure and location, but it is highly recommended you upgrade. You can create the new `.cfmigrations.json` config file by running `migrate init`. -The new config file format mirrors `CFMigrations`: +> In v5, `.cbmigrations.json` was introduced for BoxLang projects alongside `.cfmigrations.json`. +> As of v6, `.cbmigrations.json` is the universal standard for all projects. ```json { @@ -22,7 +63,7 @@ The new config file format mirrors `CFMigrations`: "properties": { "defaultGrammar": "MySQLGrammar@qb", "schema": "${DB_SCHEMA}", - "migrationsTable": "cfmigrations", + "migrationsTable": "cbmigrations", "connectionInfo": { "host": "${DB_HOST}", "username": "${DB_USER}", @@ -46,7 +87,7 @@ More managers can be added as new top-level keys: "properties": { "defaultGrammar": "MySQLGrammar@qb", "schema": "${DB_SCHEMA}", - "migrationsTable": "cfmigrations", + "migrationsTable": "cbmigrations", "connectionInfo": { "host": "${DB_HOST}", "username": "${DB_USER}", @@ -63,7 +104,7 @@ More managers can be added as new top-level keys: "properties": { "defaultGrammar": "MySQLGrammar@qb", "schema": "${DB_SCHEMA}", - "migrationsTable": "cfmigrations2", + "migrationsTable": "cbmigrations_alternate", "connectionInfo": { "host": "${DB_HOST}", "username": "${DB_USER}", @@ -84,7 +125,7 @@ Make sure to append `@qb` to the end of any qb-supplied grammars, like `AutoDisc ## Setup -You need to create a `.cfmigrations.json` config file in your application root folder. You can do this easily by running `migrate init`: +You need to create a `.cbmigrations.json` config file in your application root folder. You can do this easily by running `migrate init`. ```json { @@ -95,7 +136,7 @@ You need to create a `.cfmigrations.json` config file in your application root f "properties": { "defaultGrammar": "MySQLGrammar@qb", "schema": "${DB_SCHEMA}", - "migrationsTable": "cfmigrations", + "migrationsTable": "cbmigrations", "connectionInfo": { "host": "${DB_HOST}", "username": "${DB_USER}", @@ -128,9 +169,9 @@ The `migrationsDirectory` sets the default location for the migration scripts. The `seedsDirectory` sets the default location for the seeder scripts. This setting is optional. > When using MySQL with CommandBox 5 or greater, two additional elements are required in the `connectionInfo` struct: -> `"bundleName":"com.mysql.cj"` and `"bundleVersion":"8.0.15"` +> `"bundleName":"com.mysql.cj"` and `"bundleVersion":"8.0.15"`. These will dissapear in the next iteration as we migrate to BoxLang. -`commandbox-migrations` will create a datasource named `cfmigrations` from the information you specify. +`commandbox-migrations` will create a datasource named `cbmigrations` from the information you specify. You can use this in your queries: ```js @@ -143,11 +184,11 @@ queryExecute( ) ", [], - { datasource = "cfmigrations" } + { datasource = "cbmigrations" } ) ``` -`commandbox-migrations` will also set `cfmigrations` as the default datasource, so the following will work as well: +`commandbox-migrations` will also set `cbmigrations` as the default datasource, so the following will work as well: ```js queryExecute( " @@ -183,10 +224,9 @@ DB_USER=test DB_PASSWORD=pass1234 ``` - I recommend adding this file to your `.gitignore` -``` +```bash .env ``` @@ -202,32 +242,103 @@ DB_PASSWORD= You would update your `.gitignore` file to not ignore the `.env.example` file: -``` +```bash .env !.env.example ``` +## BoxLang Support + +`commandbox-migrations` fully supports [BoxLang](https://boxlang.io) projects: + +- Scaffolding: `migrate create` and `migrate seed create` generate `.bx` files + instead of `.cfc` files when a BoxLang project is detected (or when `--boxlang` is passed). +- Config: `.cbmigrations.json` is used for all projects (BoxLang and CFML alike) as of v6. + +Whether a project is treated as BoxLang is auto-detected by checking, in order: + +1. Whether the running CommandBox server's engine is BoxLang. +2. Whether `box.json` has `"language": "boxlang"`. + +You can skip auto-detection and force the behavior on any of `migrate init`, +`migrate create`, and `migrate seed create` with the `--boxlang` / `--no-boxlang` flags. + ## Usage -### `migrate init` +Every command below accepts an optional `manager` argument (defaulting to `default`) +to target a specific named manager from your config file. See the config examples +in the upgrade sections above for how to define multiple managers. + +### `migrate init [--open] [--boxlang] [--no-boxlang]` -Creates the migration config file as `.cfmigrations.json`, if it doesn't already exist. +Creates the migration config file (`.cbmigrations.json`) if it doesn't already exist. +Pass `--boxlang`/`--no-boxlang` to control whether `.bx` or `.cfc` scaffolding is +generated by `migrate create` and `migrate seed create`. -### `migrate install` +Passing `--open` opens the config file once it's created. -Installs the migration table in to your database. +### `migrate install [manager] [--verbose]` + +Installs the migration table for the given manager in to your database. This migration table keeps track of the ran migrations. -### `migrate create [name]` +Passing the `--verbose` flag will show the resolved migrations config +as well as the full stack trace of any errors. + +### `migrate status [manager] [--verbose] [--json]` + +Displays the current migration status for the given manager — including +the configured directory, tracking table state, applied/pending counts, +the current database revision, and a table of all migration files with +their status. + +When the database is unreachable, the command degrades gracefully and +shows the migration files present on disk with an unknown status. + +Passing `--verbose` shows the resolved migrations configuration above the +status table. -Creates a migration file with an `up` and `down` method. +Passing `--json` outputs the status as a JSON object for CI/CD scripting: + +```json +{ + "manager": "default", + "directory": "resources/database/migrations/", + "dbAvailable": true, + "tableInstalled": true, + "applied": 1, + "pending": 1, + "total": 2, + "currentRevision": "2022_11_01_192710_create_users_table", + "migrations": [ + { + "componentName": "2022_11_01_192710_create_users_table", + "timestamp": "2022-11-01 19:27:10", + "migrated": true, + "canMigrateUp": false, + "canMigrateDown": true + } + ] +} +``` + +### `migrate create [name] [manager] [--open] [--boxlang] [--no-boxlang]` + +Creates a migration file with an `up` and `down` method for the given manager. The file name will be prepended with the current timestamp -in the format that `cfmigrations` expects. +in the format that `cfmigrations` expects. Creates a `.cfc` file by +default, or a `.bx` file for BoxLang projects (auto-detected, or +overridden with `--boxlang`/`--no-boxlang`). + +Passing `--open` opens the migration file once it's created. -### `migrate up [--once] [--verbose] [--pretend] [file]` +### `migrate up [manager] [--seed] [--once] [--verbose] [--pretend] [file]` -Runs all available migrations up. Passing the `--once` flag will only -run a single migration up (if any are available). +Runs all available migrations up for the given manager. Passing the `--once` +flag will only run a single migration up (if any are available). + +Passing the `--seed` flag will run all seeders for the manager after the +migrations are applied (equivalent to running `migrate seed run` afterward). Passing the `--verbose` flag with show the datasource information passed as well as the full stack trace of any errors. @@ -241,10 +352,10 @@ If provided, the outputted sql will be saved to the file path provided. > **WARNING: `--pretend` only captures SQL from `schema` (SchemaBuilder) and `qb` (QueryBuilder) calls.** > Migrations that use `queryExecute()` directly are **not intercepted** — those queries **will execute against your database** even when `--pretend` is passed. If your migrations use raw `queryExecute()` calls, do not rely on `--pretend` to prevent changes. -### `migrate down [--once] [--verbose] [--pretend] [file]` +### `migrate down [manager] [--once] [--verbose] [--pretend] [file]` -Runs all available migrations down. Passing the `--once` flag will only -run a single migration down (if any are available). +Runs all available migrations down for the given manager. Passing the +`--once` flag will only run a single migration down (if any are available). Passing the `--verbose` flag with show the datasource information passed as well as the full stack trace of any errors. @@ -258,28 +369,48 @@ If provided, the outputted sql will be saved to the file path provided. > **WARNING: `--pretend` only captures SQL from `schema` (SchemaBuilder) and `qb` (QueryBuilder) calls.** > Migrations that use `queryExecute()` directly are **not intercepted** — those queries **will execute against your database** even when `--pretend` is passed. If your migrations use raw `queryExecute()` calls, do not rely on `--pretend` to prevent changes. -### `migrate refresh` +### `migrate refresh [manager] [--seed] [--verbose]` -Runs all available migrations down and then runs all migrations up. +Runs all available migrations down and then runs all migrations up for the +given manager (delegates to `migrate down` and `migrate up`, forwarding +`manager`, `--seed`, and `--verbose`). -### `migrate reset` +### `migrate reset [manager] [--verbose]` -Clears out all objects from the database, including the `cfmigrations` table. -Use this when your database is in an inconsistent state in development. +Clears out all objects from the database, including the `cbmigrations` table, +for the given manager. Use this when your database is in an inconsistent +state in development. -### `migrate fresh` +Passing the `--verbose` flag will show the resolved migrations config +as well as the full stack trace of any errors. -Runs `migrate reset`, `migrate install`, and `migrate up` to give you -a fresh copy of your migrated database. +### `migrate fresh [manager] [--seed] [--verbose]` -### `migrate uninstall` +Runs `migrate reset`, `migrate install`, and `migrate up` (forwarding +`manager`, `--seed`, and `--verbose`) to give you a fresh copy of your +migrated database. -Removes the `cfmigrations` table after running down any ran migrations. +### `migrate uninstall [manager] [--verbose] [--force]` -### `migrate seed create [name]` +Removes the `cbmigrations` table for the given manager after running down +any ran migrations. Prompts for confirmation before uninstalling unless +`--force` is passed. -Creates a new Seeder file. +Passing the `--verbose` flag will show the resolved migrations config +as well as the full stack trace of any errors. -### `migrate seed run` +### `migrate seed create [name] [manager] [--open] [--boxlang] [--no-boxlang]` -Runs one or all Seeders. +Creates a new Seeder file for the given manager. Creates a `.cfc` file by +default, or a `.bx` file for BoxLang projects (auto-detected, or +overridden with `--boxlang`/`--no-boxlang`). + +Passing `--open` opens the seeder file once it's created. + +### `migrate seed run [name] [manager] [--verbose]` + +Runs one or all Seeders for the given manager. Pass `name` to only run a +single named seeder; omit it to run all seeders. + +Passing the `--verbose` flag will show the resolved migrations config +as well as the full stack trace of any errors. diff --git a/box.json b/box.json index f60e33a..0feccab 100644 --- a/box.json +++ b/box.json @@ -1,5 +1,5 @@ { - "name":"CFMigrations Commands", + "name":"ColdBox Migrations Commands", "version":"5.3.0", "location":"forgeboxStorage", "author":"Eric Peterson", @@ -11,19 +11,20 @@ }, "bugs":"https://github.com/commandbox-modules/commandbox-migrations/issues", "slug":"commandbox-migrations", - "shortDescription":"Run your cfmigrations from CommandBox", + "shortDescription":"Run your ColdBox migrations from CommandBox", "instructions":"https://github.com/commandbox-modules/commandbox-migrations", "type":"commandbox-modules", "keywords":[ "server", "database", "migrations", - "cfmigrations" + "cbmigrations" ], "scripts":{ "onRelease":"publish", "postPublish":"!git push && git push --tags", - "format":"cfformat run commands/**/*.cfc,ModuleConfig.cfc --overwrite" + "format":"cfformat run commands/**/*.cfc,ModuleConfig.cfc --overwrite", + "test":"task run taskFile=tests/Runner.cfc" }, "private":false, "license":[ @@ -36,8 +37,12 @@ "cfmigrations":"^5.1.0", "sqlformatter":"^1.1.3+31" }, + "devDependencies":{ + "testbox":"*" + }, "installPaths":{ "cfmigrations":"modules/cfmigrations/", - "sqlformatter":"modules/sqlformatter/" + "sqlformatter":"modules/sqlformatter/", + "testbox":"testbox/" } } diff --git a/boxlang_modules/bx-mysql/ModuleConfig.bx b/boxlang_modules/bx-mysql/ModuleConfig.bx new file mode 100644 index 0000000..6865309 --- /dev/null +++ b/boxlang_modules/bx-mysql/ModuleConfig.bx @@ -0,0 +1,133 @@ +/** + * This is the module descriptor and entry point for your module in the Runtime. + * The unique name of the moduel is the name of the directory on the modules folder. + * A BoxLang Mapping will be created for you with the name of the module. + * + * A Module can have the following folders that will be automatically registered: + * + bifs - Custom BIFs that will be registered into the runtime + * + interceptors - Custom Interceptors that will be registered into the runtime via the configure() method + * + libs - Custom Java libraries that your module leverages + * + tags - Custom tags that will be registered into the runtime + * + * Every Module will have it's own ClassLoader that will be used to load the module libs and dependencies. + */ + +class { + + property name="moduleRecord"; + property name="boxRuntime"; + property name="log"; + + /** + * -------------------------------------------------------------------------- + * Module Properties + * -------------------------------------------------------------------------- + * Here is where you define the properties of your module that the module service + * will use to register and activate your module + */ + + /** + * Your module version. Try to use semantic versioning + * @mandatory + */ + this.version = "1.0.1+7"; + + /** + * The BoxLang mapping for your module. All BoxLang modules are registered with an internal + * mapping prefix of : bxModules.{this.mapping}, /bxmodules/{this.mapping}. Ex: bxModules.test, /bxmodules/test + */ + this.mapping = "bxmysql"; + + /** + * Who built the module + */ + this.author = "Luis Majano"; + + /** + * The module description + */ + this.description = "This module provides MySQL JDBC Support"; + + /** + * The module web URL + */ + this.webURL = "https://www.ortussolutions.com"; + + /** + * This boolean flag tells the module service to skip the module registration/activation process. + */ + this.disabled = false; + + /** + * -------------------------------------------------------------------------- + * Module Methods + * -------------------------------------------------------------------------- + */ + + /** + * Called by the ModuleService on module registration + */ + function configure(){ + /** + * Every module has a settings configuration object + */ + settings = { + loadedOn : now() + }; + + /** + * Every module can have a list of object mappings + * that can be created by boxLang. This is a great way + * to create objects that can be used by the module + * or other modules. + * The mappings will be created in the following format: + * bxModules.{this.mapping}.{mappingName} + * Ex: bxModules.test.MyObject => bxModules.test.models.MyObject + */ + objectMappings = { + // { name="MyObject", class="models.utilities.MyObject" } + } + + /** + * DataSources can be defined by a module and they will be registered + * for you in the runtime + */ + datasources = { + // { name="MyDSN", class="coldbox.system.datasources.ColdBoxDataSource", properties={dsn="mydsn"} } + }; + + /** + * The module interceptors to register into the runtime + */ + interceptors = [ + ]; + + /** + * A list of custom interception points to register into the runtime + */ + customInterceptionPoints = []; + } + + /** + * Called by the ModuleService on module activation + */ + function onLoad(){ + + } + + /** + * Called by the ModuleService on module deactivation + */ + function onUnload(){ + + } + + /** + * -------------------------------------------------------------------------- + * Module Events + * -------------------------------------------------------------------------- + * You can listen to any Runtime events by creating the methods + * that match the approved Runtime Interception Points + */ + +} diff --git a/boxlang_modules/bx-mysql/box.json b/boxlang_modules/bx-mysql/box.json new file mode 100644 index 0000000..3837438 --- /dev/null +++ b/boxlang_modules/bx-mysql/box.json @@ -0,0 +1,50 @@ +{ + "name":"BoxLang MySQL", + "version":"1.0.1+7", + "location":"https://downloads.ortussolutions.com/ortussolutions/boxlang-modules/bx-mysql/1.0.1/bx-mysql-1.0.1.zip", + "author":"Ortus Solutions", + "homepage":"https://github.com/ortus-boxlang/bx-mysql", + "documentation":"https://github.com/ortus-boxlang/bx-mysql", + "repository":{ + "type":"git", + "URL":"https://github.com/ortus-boxlang/bx-mysql" + }, + "bugs":"https://github.com/ortus-boxlang/bx-mysql/issues", + "slug":"bx-mysql", + "shortDescription":"MySQL JDBC Support for BoxLang", + "type":"boxlang-modules", + "keywords":[ + "boxlang" + ], + "boxlang":{ + "minimumVersion":"1.0.0", + "moduleName":"mysql" + }, + "private":false, + "license":[ + { + "type":"Apache-2.0", + "URL":"https://www.apache.org/licenses/LICENSE-2.0" + } + ], + "devDependencies":{ + "commandbox-cfformat":"*", + "commandbox-docbox":"*", + "commandbox-dotenv":"*", + "commandbox-cfconfig":"*", + "testbox":"*" + }, + "ignore":[ + "**/.*", + "settings.gradle", + "gradlew.bat", + "gradlew", + "build.gradle", + "/src/**", + "gradle/**" + ], + "scripts":{ + "setupTemplate":"task run taskFile=src/build/SetupTemplate.cfc", + "onRelease":"publish" + } +} diff --git a/boxlang_modules/bx-mysql/changelog.md b/boxlang_modules/bx-mysql/changelog.md new file mode 100644 index 0000000..8f31cc0 --- /dev/null +++ b/boxlang_modules/bx-mysql/changelog.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +* * * + +## [Unreleased] + +## [1.0.1] - 2025-06-24 + +## [1.0.1] - 2025-06-06 + +### 🔐 Security + +- Bumps com.mysql:mysql-connector-j from 9.2.0 to 9.3.0. +- Bumped `mysql-connector-j` to version 9.2.0 to address [SNYK-JAVA-COMGOOGLEPROTOBUF-8055227](https://security.snyk.io/vuln/SNYK-JAVA-COMGOOGLEPROTOBUF-8055227) + +## [1.0.0] - 2024-06-13 + +- First iteration of this module + +[unreleased]: https://github.com/ortus-boxlang/bx-mysql/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/ortus-boxlang/bx-mysql/compare/v1.0.1...v1.0.1 +[1.0.0]: https://github.com/ortus-boxlang/bx-mysql/compare/f2ce71dad5581aa57b4c657144a175f7209dea47...v1.0.0 diff --git a/boxlang_modules/bx-mysql/libs/bx-mysql-1.0.1.jar b/boxlang_modules/bx-mysql/libs/bx-mysql-1.0.1.jar new file mode 100644 index 0000000..bfcb0eb Binary files /dev/null and b/boxlang_modules/bx-mysql/libs/bx-mysql-1.0.1.jar differ diff --git a/boxlang_modules/bx-mysql/readme.md b/boxlang_modules/bx-mysql/readme.md new file mode 100644 index 0000000..3e15238 --- /dev/null +++ b/boxlang_modules/bx-mysql/readme.md @@ -0,0 +1,27 @@ +# ⚡︎ BoxLang Module: MySQL JDBC Driver + +``` +|:------------------------------------------------------:| +| ⚡︎ B o x L a n g ⚡︎ +| Dynamic : Modular : Productive +|:------------------------------------------------------:| +``` + +
+ Copyright Since 2023 by Ortus Solutions, Corp +
+ www.boxlang.io | + www.ortussolutions.com +
+ +

 

+ +This module provides a BoxLang JDBC driver for MySQL. This module is part of the BoxLang project. + +## Ortus Sponsors + +BoxLang is a professional open-source project and it is completely funded by the [community](https://patreon.com/ortussolutions) and [Ortus Solutions, Corp](https://www.ortussolutions.com). Ortus Patreons get many benefits like a cfcasts account, a FORGEBOX Pro account and so much more. If you are interested in becoming a sponsor, please visit our patronage page: [https://patreon.com/ortussolutions](https://patreon.com/ortussolutions) + +### THE DAILY BREAD + + > "I am the way, and the truth, and the life; no one comes to the Father, but by me (JESUS)" Jn 14:1-12 diff --git a/commands/migrate/create.cfc b/commands/migrate/create.cfc index 7f62810..e399da1 100644 --- a/commands/migrate/create.cfc +++ b/commands/migrate/create.cfc @@ -4,40 +4,59 @@ * * It prepends the date at the beginning of the file name so * you can keep your migrations in the correct order. + * + * {code:bash} + * ## Create a simple migration + * migrate create CreateUsersTable + * + * ## Create and immediately open the migration for editing + * migrate create AddEmailToUsers --open + * + * ## Create a BoxLang migration (.bx) + * migrate create CreateProductsTable --boxlang + * + * ## Create a migration for a named manager + * migrate create CreateOrdersTable --manager=secondary + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @name.hint Name of the migration to create without the .cfc. - * @manager.hint The Migration Manager to use. + * @name Name of the migration to create without the extension. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @open.hint Open the file once generated. + * @open Open the file once generated. + * @boxlang Create a .bx file instead of a .cfc. Defaults to auto-detection based on your server/box.json. */ - function run( required string name, string manager = "default", boolean open = false ) { - setup( manager = arguments.manager, setupDatasource = false ); - - var migrationsDirectory = expandPath( variables.migrationService.getMigrationsDirectory() ); + function run( + required string name, + string manager = "default", + boolean open = false, + boolean boxlang = isBoxLangProject( getCWD() ) + ) { + setup( manager = arguments.manager, setupDatasource = false ) + var migrationsDirectory = expandPath( variables.migrationService.getMigrationsDirectory() ) // Validate migrationsDirectory if ( !directoryExists( migrationsDirectory ) ) { - directoryCreate( migrationsDirectory ); + directoryCreate( migrationsDirectory ) } - var timestamp = dateTimeFormat( now(), "yyyy_mm_dd_HHnnss" ); - var migrationPath = "#migrationsDirectory##timestamp#_#arguments.name#.cfc"; - - var migrationContent = fileRead( "/commandbox-migrations/templates/Migration.txt" ); + var extension = arguments.boxlang ? "bx" : "cfc" + var timestamp = dateTimeFormat( now(), "yyyy_MM_dd_HHnnss" ) + var migrationPath = "#migrationsDirectory##timestamp#_#arguments.name#.#extension#" + var migrationContent = fileRead( "/commandbox-migrations/templates/Migration#arguments.boxlang ? "BX" : ""#.txt" ) file action="write" file="#migrationPath#" mode="777" output="#trim( migrationContent )#"; - print.greenLine( "Created #migrationPath#" ); + print.greenLine( "Created #migrationPath#" ) // Open file? if ( arguments.open ) { - openPath( migrationPath ); + openPath( migrationPath ) } - return; + return } } diff --git a/commands/migrate/down.cfc b/commands/migrate/down.cfc index de14e90..7fca7b5 100644 --- a/commands/migrate/down.cfc +++ b/commands/migrate/down.cfc @@ -1,28 +1,53 @@ /** * Rollback one or all of the migrations already ran against your database. + * + * Migrations are rolled back in reverse chronological order (newest first). + * Each migration's `down()` method is called to reverse the changes. + * + * {code:bash} + * ## Roll back all applied migrations + * migrate down + * + * ## Roll back only the last applied migration + * migrate down --once + * + * ## Preview rollback SQL without executing + * migrate down --pretend + * + * ## Save the pretend SQL output to a file + * migrate down --pretend --file=rollback.sql + * + * ## Roll back migrations for a named manager + * migrate down --manager=secondary + * + * ## Roll back with verbose error output + * migrate down --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @once.hint Only rollback a single migration. - * @manager.hint The Migration Manager to use. + * @once Only rollback a single migration. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @verbose.hint If true, errors output a full stack trace. - * @pretend.hint If true, only pretends to run the query. The SQL that would have been run is printed to the console. - * @file.hint If provided, outputs the SQL that would have been run to the file. Only applies when running `pretend`. + * @verbose If true, errors output a full stack trace. + * @pretend If true, only pretends to run the query. The SQL that would have been run is printed to the console. + * @file If provided, outputs the SQL that would have been run to the file. Only applies when running `pretend`. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ function run( boolean once = false, string manager = "default", boolean verbose = false, boolean pretend = false, - string file + string file, + boolean installDrivers = true ) { - setup( arguments.manager ); + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ) if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } pagePoolClear(); @@ -33,19 +58,19 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { checkForInstalledMigrationTable(); if ( !variables.migrationService.hasMigrationsToRun( "down" ) ) { - print.line().yellowLine( "No migrations to rollback." ).line(); + print.line().yellowLine( "📭 No migrations to rollback." ).line(); } else if ( arguments.once ) { variables.migrationService.runNextMigration( direction = "down", preProcessHook = ( migration ) => { currentlyRunningMigration = migration; - print.yellow( "Rolling back: " ).line( migration.componentName ).toConsole(); + print.yellow( "⏪ Rolling back: " ).line( migration.componentName ).toConsole(); }, postProcessHook = ( migration, schema, qb ) => { if (!pretend) { - print.green( "Rolled back: " ).line( migration.componentName ).toConsole(); + print.green( "✅ Rolled back: " ).line( migration.componentName ).toConsole(); } else { - print.green( "Pretended to roll back: " ).line( migration.componentName ).toConsole(); + print.green( "🧪 Pretended to roll back: " ).line( migration.componentName ).toConsole(); print.line(); for ( var q in schema.getQueryLog() ) { var inlineSql = qb.getUtils().replaceBindings( q.sql, q.bindings, true ); @@ -68,13 +93,13 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { direction = "down", preProcessHook = ( migration ) => { currentlyRunningMigration = migration; - print.yellow( "Rolling back: " ).line( migration.componentName ).toConsole(); + print.yellow( "⏪ Rolling back: " ).line( migration.componentName ).toConsole(); }, postProcessHook = ( migration, schema, qb ) => { if (!pretend) { - print.green( "Rolled back: " ).line( migration.componentName ).toConsole(); + print.green( "✅ Rolled back: " ).line( migration.componentName ).toConsole(); } else { - print.green( "Pretended to roll back: " ).line( migration.componentName ).toConsole(); + print.green( "🧪 Pretended to roll back: " ).line( migration.componentName ).toConsole(); print.line(); for ( var q in schema.getQueryLog() ) { var inlineSql = qb.getUtils().replaceBindings( q.sql, q.bindings, true ); @@ -96,7 +121,7 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { } catch ( any e ) { if ( arguments.verbose ) { if ( structKeyExists( e, "Sql" ) ) { - print.whiteOnRedLine( "Error when trying to run #currentlyRunningMigration.componentName#:" ); + print.whiteOnRedLine( "❌ Error when trying to roll back #currentlyRunningMigration.componentName#:" ); print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); } rethrow; diff --git a/commands/migrate/fresh.cfc b/commands/migrate/fresh.cfc index 24fa27b..79a1b46 100644 --- a/commands/migrate/fresh.cfc +++ b/commands/migrate/fresh.cfc @@ -1,20 +1,46 @@ /** - * Resets the database and runs all migrations up. + * Drop all database objects and re-run every migration from scratch. + * + * WARNING: This is a destructive operation! It calls `migrate reset` to wipe + * the entire database schema, then re-installs the migrations table and applies + * all migrations in order. All data will be lost. + * + * Use this command to get a clean-slate database during development. + * + * {code:bash} + * ## Drop everything and re-run all migrations + * migrate fresh + * + * ## Drop everything, re-run migrations, then seed the database + * migrate fresh --seed + * + * ## Run a fresh migration for a named manager + * migrate fresh --manager=secondary + * + * ## Run with verbose error output + * migrate fresh --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @manager.hint The Migration Manager to use. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @seed.hint If true, runs all seeders for the manager after creating a fresh database. - * @verbose.hint If true, errors output a full stack trace. + * @seed If true, runs all seeders for the manager after creating a fresh database. + * @verbose If true, errors output a full stack trace. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ - function run( string manager = "default", boolean seed = false, boolean verbose = false ) { - setup( arguments.manager ); + function run( + string manager = "default", + boolean seed = false, + boolean verbose = false, + boolean installDrivers = true + ) { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } pagePoolClear(); diff --git a/commands/migrate/help.cfc b/commands/migrate/help.cfc new file mode 100644 index 0000000..f176119 --- /dev/null +++ b/commands/migrate/help.cfc @@ -0,0 +1,93 @@ +/** + * Display help and usage information for the migrate commands. + */ +component excludeFromHelp=true extends="commandbox-migrations.models.BaseMigrationCommand" { + + function run() { + print + .line() + .boldCyan( "CommandBox Migrations (cbmigrations)" ) + .line() + .line() + .whiteLine( "Manage your database schema and data using versioned migration files." ) + .whiteLine( "All migrations are tracked in a migrations table so every change is recorded." ) + .line() + .boldWhiteLine( "Setup:" ) + .line() + .greenLine( " migrate init Initialize your project with a .cbmigrations.json config file" ) + .greenLine( " migrate install Install the migrations tracking table in your database" ) + .greenLine( " migrate uninstall Remove the migrations tracking table from your database" ) + .line() + .boldWhiteLine( "Running Migrations:" ) + .line() + .greenLine( " migrate up Apply one or all pending migrations" ) + .greenLine( " migrate down Rollback one or all applied migrations" ) + .greenLine( " migrate fresh Drop all objects and re-run all migrations (destructive!)" ) + .greenLine( " migrate refresh Rollback all migrations then re-run them (reset + up)" ) + .greenLine( " migrate reset Reset the database by clearing all objects" ) + .line() + .boldWhiteLine( "Scaffolding:" ) + .line() + .greenLine( " migrate create Create a new migration file" ) + .line() + .boldWhiteLine( "Seeders:" ) + .line() + .greenLine( " migrate seed run Run one or all database seeders" ) + .greenLine( " migrate seed create Create a new seeder file" ) + .line() + .yellowLine( "Examples:" ) + .line() + .dim( " ## Initialize migrations for a new project" ) + .line( " migrate init" ) + .line() + .dim( " ## Install the migrations table, then run all migrations" ) + .line( " migrate install && migrate up" ) + .line() + .dim( " ## Create and immediately open a new migration" ) + .line( " migrate create CreateUsersTable --open" ) + .line() + .dim( " ## Apply only the next pending migration" ) + .line( " migrate up --once" ) + .line() + .dim( " ## Roll back only the last applied migration" ) + .line( " migrate down --once" ) + .line() + .dim( " ## Preview the SQL that would run without executing it" ) + .line( " migrate up --pretend" ) + .line() + .dim( " ## Fresh database with seed data" ) + .line( " migrate fresh --seed" ) + .line() + .dim( " ## Use a named manager (multi-database support)" ) + .line( " migrate up --manager=secondary" ) + .line() + .dim( " ## Skip auto-installing the BoxLang JDBC driver (CI / offline mode)" ) + .line( " migrate up --installDrivers=false" ) + .line() + .line() + .boldWhiteLine( "BoxLang Driver Auto-Install:" ) + .line() + .whiteLine( " When running inside a BoxLang server engine, cbmigrations will" ) + .whiteLine( " automatically detect the JDBC driver from your .cbmigrations.json" ) + .whiteLine( " `connectionInfo` (via the `driver`, `type`, or `connectionString` key)" ) + .whiteLine( " and install the matching `bx-*` module from ForgeBox into" ) + .whiteLine( " `boxlang_modules/` if it is not already present." ) + .line() + .whiteLine( " Supported drivers:" ) + .greenLine( " mysql -> bx-mysql | mariadb -> bx-mariadb" ) + .greenLine( " postgresql -> bx-postgresql | mssql -> bx-mssql" ) + .greenLine( " oracle -> bx-oracle | sqlite -> bx-sqlite" ) + .greenLine( " derby -> bx-derby | h2/hsql -> bx-hypersql" ) + .line() + .whiteLine( " Pass --installDrivers=false to any DB-touching command to opt out of" ) + .whiteLine( " the auto-install (useful for CI / offline environments)." ) + .line() + .line() + .yellowLine( "Tip: Type 'migrate --help' for detailed options on any command" ) + .line() + .dim( "Documentation: https://forgebox.io/view/commandbox-migrations" ) + .line() + .line() + } + +} diff --git a/commands/migrate/init.cfc b/commands/migrate/init.cfc index 4eb8ea1..828629c 100644 --- a/commands/migrate/init.cfc +++ b/commands/migrate/init.cfc @@ -1,34 +1,82 @@ /** - * Initialize your project to use commandbox-migrations - * Make sure you are running this command in the root of your app. + * Initialize your project to use commandbox-migrations. * - * This will ensure the correct values are set in your box.json. + * Creates a `.cbmigrations.json` configuration file in the current working + * directory with sensible defaults. Edit this file to configure your database + * connection, migrations directory, seeders directory, and named managers for + * multi-database support. + * + * If a legacy `.cfmigrations.json` file is detected, you will be prompted to + * rename it to `.cbmigrations.json` instead of creating a new blank config. + * + * Run this command once when setting up migrations for the first time, then + * follow up with `migrate install` to create the tracking table in your database. + * + * {code:bash} + * ## Initialize migrations config in the current directory + * migrate init + * + * ## Initialize and immediately open the config file for editing + * migrate init --open + * {code} */ -component { +component extends="commandbox-migrations.models.BaseMigrationCommand" { - property name="packageService" inject="PackageService"; - property name="JSONService" inject="JSONService"; + /** + * Initialize your project to use commandbox-migrations. + * Make sure you are running this command in the root of your app. + * + * @open Open the config file after it is created. + */ + function run( + boolean open = false + ) { + var directory = getCWD() + var configFileName = ".cbmigrations.json" + var configPath = "#directory##configFileName#" + var legacyPath = "#directory#.cfmigrations.json" - function run( boolean open = false ) { - var directory = getCWD(); + // Check and see if the new config file already exists + if ( fileExists( configPath ) ) { + print.yellowLine( "#configFileName# already exists." ) + return + } - var configPath = "#directory#/.cfmigrations.json"; + // Detect legacy .cfmigrations.json and offer to migrate it + if ( fileExists( legacyPath ) ) { + print.line() + print.boldYellowLine( "A legacy '.cfmigrations.json' configuration file was detected." ) + print.yellowLine( "The config file has been renamed to '.cbmigrations.json' in this version of Migrations." ) + print.line() - // Check and see if a .cfmigrations.json file exists - if ( fileExists( configPath ) ) { - print.yellowLine( ".cfmigrations.json already exists." ); - return; + if ( confirm( "Would you like to rename '.cfmigrations.json' to '.cbmigrations.json' now? [y/n]" ) ) { + fileMove( legacyPath, configPath ) + print.greenLine( "Renamed '.cfmigrations.json' to '.cbmigrations.json' successfully." ) + print.line() + } else { + print.yellowLine( "Skipped rename. Your '.cfmigrations.json' is still in use, but consider renaming it manually." ) + print.line() + return + } + + // Open file? + if ( arguments.open ) { + openPath( configPath ) + } + + return } - var configStub = fileRead( "/commandbox-migrations/templates/config.txt" ); + // Create a fresh config from the template + var configStub = fileRead( "/commandbox-migrations/templates/config.txt" ) file action="write" file="#configPath#" mode="777" output="#trim( configStub )#"; - print.greenLine( "Created .cfmigrations config file." ); + print.greenLine( "Created #configFileName# config file." ) // Open file? if ( arguments.open ) { - openPath( configPath ); + openPath( configPath ) } } diff --git a/commands/migrate/install.cfc b/commands/migrate/install.cfc index a30d11c..1034d7a 100644 --- a/commands/migrate/install.cfc +++ b/commands/migrate/install.cfc @@ -1,36 +1,52 @@ /** - * Installs the cfmigrations table in to your database. + * Install the migrations tracking table into your database. * - * The cfmigrations table keeps track of the migrations ran against your database. - * It must be installed before running any migrations. + * The migrations table records every migration that has been applied, allowing + * cbmigrations to know which migrations are pending and which have been run. + * This command must be run before executing `migrate up` for the first time. + * + * Running this command when the table already exists will display a message + * and exit gracefully without making any changes. + * + * {code:bash} + * ## Install the migrations table + * migrate install + * + * ## Install for a named manager + * migrate install --manager=secondary + * + * ## Install with verbose output + * migrate install --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @manager.hint The Migration Manager to use. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @verbose.hint If true, errors will output a full stack trace. + * @verbose If true, errors will output a full stack trace. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ - function run( string manager = "default", boolean verbose = false ) { - setup( arguments.manager ); + function run( string manager = "default", boolean verbose = false, boolean installDrivers = true ) { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); if ( verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } try { if ( variables.migrationService.isReady() ) { - print.line( "Migration table already installed." ); + print.yellowLine( "ℹ️ Migration table already installed." ); return; } variables.migrationService.install(); - print.line( "Migration table installed!" ).line(); + print.greenLine( "✅ Migration table installed!" ).line(); } catch ( any e ) { if ( verbose ) { if ( structKeyExists( e, "Sql" ) ) { - print.whiteOnRedLine( "Error when trying to reset the database:" ); + print.whiteOnRedLine( "❌ Error when trying to install the migration table:" ); print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); } rethrow; diff --git a/commands/migrate/refresh.cfc b/commands/migrate/refresh.cfc index b428364..2768003 100644 --- a/commands/migrate/refresh.cfc +++ b/commands/migrate/refresh.cfc @@ -1,20 +1,44 @@ /** * Rollback all committed migrations and then apply all migrations in order. + * + * This is the equivalent of running `migrate down` followed by `migrate up`. + * Unlike `migrate fresh`, this uses each migration's `down()` method to + * reverse changes rather than dropping all database objects directly. + * + * {code:bash} + * ## Roll back all migrations then re-apply them + * migrate refresh + * + * ## Refresh and seed the database + * migrate refresh --seed + * + * ## Refresh a named manager + * migrate refresh --manager=secondary + * + * ## Refresh with verbose error output + * migrate refresh --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @manager.hint The Migration Manager to use. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @seed.hint If true, runs all seeders for the manager after creating a fresh database. - * @verbose.hint If true, errors output a full stack trace + * @seed If true, runs all seeders for the manager after creating a fresh database. + * @verbose If true, errors output a full stack trace + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ - function run( string manager = "default", boolean seed = false, boolean verbose = false ) { - setup( arguments.manager ); + function run( + string manager = "default", + boolean seed = false, + boolean verbose = false, + boolean installDrivers = true + ) { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } pagePoolClear(); diff --git a/commands/migrate/reset.cfc b/commands/migrate/reset.cfc index 9430ef4..6526b8f 100644 --- a/commands/migrate/reset.cfc +++ b/commands/migrate/reset.cfc @@ -1,28 +1,46 @@ /** - * Resets the database by clearing out all objects + * Reset the database by dropping all tables, views, and other schema objects. + * + * WARNING: This is a destructive operation! All schema objects will be dropped. + * No migration `down()` methods are called — the database is wiped directly. + * + * This command is used internally by `migrate fresh`. You can run it standalone + * when you want to clear the database without immediately re-running migrations. + * + * {code:bash} + * ## Drop all database objects + * migrate reset + * + * ## Reset a named manager's database + * migrate reset --manager=secondary + * + * ## Reset with verbose error output + * migrate reset --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @manager.hint The Migration Manager to use. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @verbose.hint If true, errors output a full stack trace. + * @verbose If true, errors output a full stack trace. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ - function run( string manager = "default", boolean verbose = false ) { - setup( arguments.manager ); + function run( string manager = "default", boolean verbose = false, boolean installDrivers = true ) { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } try { variables.migrationService.reset(); - print.greenLine( "Database reset!" ); + print.greenLine( "🔄 Database reset!" ); } catch ( any e ) { if ( arguments.verbose ) { if ( structKeyExists( e, "Sql" ) ) { - print.whiteOnRedLine( "Error when trying to reset the database:" ); + print.whiteOnRedLine( "❌ Error when trying to reset the database:" ); print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); } rethrow; diff --git a/commands/migrate/seed/create.cfc b/commands/migrate/seed/create.cfc index 5ada685..e572034 100644 --- a/commands/migrate/seed/create.cfc +++ b/commands/migrate/seed/create.cfc @@ -1,36 +1,66 @@ /** - * Create a new seeder CFC in an existing application. + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Create a new database seeder CFC in an existing application. * Make sure you are running this command in the root of your app. + * + * Seeders are used to populate your database with initial or sample data. + * Unlike migrations, seeders have no tracking — they can be run multiple times + * and each run will insert the data again. + * + * The seeder file is created in the seeds directory configured in your + * `.cbmigrations.json` file (defaults to `resources/database/seeds/`). + * + * {code:bash} + * ## Create a seeder + * migrate seed create UserSeeder + * + * ## Create a seeder and open it immediately for editing + * migrate seed create UserSeeder --open + * + * ## Create a BoxLang seeder (.bx) + * migrate seed create UserSeeder --boxlang + * + * ## Create a seeder for a named manager + * migrate seed create UserSeeder --manager=secondary + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @name.hint Name of the seeder to create without the .cfc. - * @manager.hint The Migration Manager to use. + * @name Name of the seeder to create without the extension. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @open.hint Open the file once generated. + * @open Open the file once generated. + * @boxlang Create a .bx file instead of a .cfc. Defaults to auto-detection based on your server/box.json. */ - function run( required string name, string manager = "default", boolean open = false ) { - setup( manager = arguments.manager, setupDatasource = false ); + function run( + required string name, + string manager = "default", + boolean open = false, + boolean boxlang = isBoxLangProject( getCWD() ) + ) { + setup( manager = arguments.manager, setupDatasource = false ) - var seedsDirectory = expandPath( variables.migrationService.getSeedsDirectory() ); + var seedsDirectory = expandPath( variables.migrationService.getSeedsDirectory() ) // Validate seedsDirectory if ( !directoryExists( seedsDirectory ) ) { - directoryCreate( seedsDirectory ); + directoryCreate( seedsDirectory ) } - var seedPath = "#seedsDirectory##arguments.name#.cfc"; - - var seedContent = fileRead( "/commandbox-migrations/templates/seed.txt" ); + var extension = arguments.boxlang ? "bx" : "cfc" + var seedPath = "#seedsDirectory##arguments.name#.#extension#" + var seedContent = fileRead( "/commandbox-migrations/templates/seed#arguments.boxlang ? "BX" : ""#.txt" ) file action="write" file="#seedPath#" mode="777" output="#trim( seedContent )#"; - print.greenLine( "Created #seedPath#" ); + print.greenLine( "Created #seedPath#" ) // Open file? if ( arguments.open ) { - openPath( seedPath ); + openPath( seedPath ) } } diff --git a/commands/migrate/seed/help.cfc b/commands/migrate/seed/help.cfc new file mode 100644 index 0000000..19ef275 --- /dev/null +++ b/commands/migrate/seed/help.cfc @@ -0,0 +1,48 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Display help and usage information for the migrate seed commands. + */ +component excludeFromHelp=true extends="commandbox-migrations.models.BaseMigrationCommand" { + + function run() { + print + .line() + .boldCyan( "Database Seeders" ) + .line() + .line() + .whiteLine( "Seeders populate your database with initial or sample data." ) + .whiteLine( "Unlike migrations, seeders can be run multiple times — each run inserts data again." ) + .line() + .boldWhiteLine( "Commands:" ) + .line() + .greenLine( " migrate seed run Run all seeders or a specific named seeder" ) + .greenLine( " migrate seed create Create a new seeder file" ) + .line() + .yellowLine( "Examples:" ) + .line() + .dim( " ## Run all seeders" ) + .line( " migrate seed run" ) + .line() + .dim( " ## Run a specific seeder by name" ) + .line( " migrate seed run UserSeeder" ) + .line() + .dim( " ## Create a new seeder and open it immediately" ) + .line( " migrate seed create UserSeeder --open" ) + .line() + .dim( " ## Create a BoxLang seeder" ) + .line( " migrate seed create UserSeeder --boxlang" ) + .line() + .dim( " ## Run seeders on a named manager" ) + .line( " migrate seed run --manager=secondary" ) + .line() + .line() + .yellowLine( "Tip: Type 'migrate seed --help' for detailed options" ) + .line() + .dim( "Documentation: https://forgebox.io/view/commandbox-migrations" ) + .line() + .line() + } + +} diff --git a/commands/migrate/seed/run.cfc b/commands/migrate/seed/run.cfc index 8dd60b5..74889d8 100644 --- a/commands/migrate/seed/run.cfc +++ b/commands/migrate/seed/run.cfc @@ -1,27 +1,55 @@ /** - * Runs one or all seeders for an application against your database. - * Seeders have no concept of being ran. - * Running a seeder multiple times will insert data multiple times. + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Run one or all database seeders for an application. + * + * Seeders populate your database with initial or sample data. They are + * typically used to provide development fixtures or default application data. + * + * Unlike migrations, seeders have no tracking — they can be run as many times + * as needed and each run will insert data again. Be careful running seeders + * against a database that already contains data. + * + * {code:bash} + * ## Run all seeders + * migrate seed run + * + * ## Run a specific seeder by name + * migrate seed run UserSeeder + * + * ## Run seeders for a named manager + * migrate seed run --manager=secondary + * + * ## Run a specific seeder with verbose output + * migrate seed run UserSeeder --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @name.hint The name of a seed to run. Runs all seeds if left blank. - * @name.optionsUDF completeSeedNames - * @manager.hint The Migration Manager to use. + * @name The name of a seed to run. Runs all seeds if left blank. + * @name.optionsUDF completeSeedNames + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @verbose.hint If true, errors output a full stack trace. + * @verbose If true, errors output a full stack trace. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ - function run( string name = "", string manager = "default", boolean verbose = false ) { - setup( arguments.manager ); + function run( + string name = "", + string manager = "default", + boolean verbose = false, + boolean installDrivers = true + ) { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); - if ( getCFMigrationsType() == "boxJSON" ) { + if ( getMigrationsConfigType() == "boxJSON" ) { error( "Seeders can only be ran after migrating to the new v4 migrations configuration." ); } if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } pagePoolClear(); @@ -32,16 +60,16 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { seedName = arguments.name == "" ? nullValue() : arguments.name, preProcessHook = ( seeder ) => { currentlyRunningSeeder = seeder; - print.yellow( "Seeding: " ).line( seeder ).toConsole(); + print.yellow( "🌱 Seeding: " ).line( seeder ).toConsole(); }, postProcessHook = ( seeder ) => { - print.green( "Seeded: " ).line( seeder ).toConsole(); + print.green( "✅ Seeded: " ).line( seeder ).toConsole(); } ); } catch ( any e ) { - if ( verbose ) { + if ( arguments.verbose ) { if ( structKeyExists( e, "Sql" ) ) { - print.whiteOnRedLine( "Error when trying to seed #currentlyRunningSeeder#:" ); + print.whiteOnRedLine( "❌ Error when trying to seed #currentlyRunningSeeder#:" ); print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); } rethrow; @@ -64,14 +92,18 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { } if ( currentlyRunningSeeder == "UNKNOWN" ) { - print.line( "No seeders to run." ); + print.yellowLine( "📭 No seeders to run." ); } } + /** + * Tab-completion options for the `name` argument, listing seed file names for + * the passed manager that start with what's been typed so far. + */ function completeSeedNames( string paramSoFar, struct passedNamedParameters ) { param passedNamedParameters.manager = "default"; setup( passedNamedParameters.manager ); - if ( getCFMigrationsType() == "boxJSON" ) { + if ( getMigrationsConfigType() == "boxJSON" ) { return []; } return variables.migrationService.findSeeds() diff --git a/commands/migrate/status.cfc b/commands/migrate/status.cfc new file mode 100644 index 0000000..1630eba --- /dev/null +++ b/commands/migrate/status.cfc @@ -0,0 +1,271 @@ +/** + * Show the current status of migrations for a manager. + * + * Displays a summary of the migration configuration, tracking table state, + * applied/pending counts, the current database revision, and a per-migration + * table showing which files are applied or pending. + * + * When the database is unreachable, the command degrades gracefully and + * shows the migration files present on disk with an unknown status. + * + * {code:bash} + * ## Show migration status + * migrate status + * + * ## Show status for a named manager + * migrate status --manager=secondary + * + * ## Output status as JSON (useful for CI/CD scripting) + * migrate status --json + * + * ## Show verbose config details + * migrate status --verbose + * {code} + */ +component extends="commandbox-migrations.models.BaseMigrationCommand" { + + /** + * @manager The Migration Manager to use. + * @manager.optionsUDF completeManagers + * @json If true, outputs the status as a JSON object. + * @verbose If true, errors output a full stack trace and config details are shown. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. + */ + function run( + string manager = "default", + boolean json = false, + boolean verbose = false, + boolean installDrivers = true + ) { + // ── Resolve config & migrations directory (always works, no DB needed) ── + var config = getMigrationsInfo() + var migrationsDir = "" + + if ( !config.keyExists( arguments.manager ) ) { + return error( + "No manager found named [#arguments.manager#].", + "Available managers are: #config.keyList( ", " )#" + ) + } + + var managerConfig = config[ arguments.manager ] + migrationsDir = len( trim( managerConfig.migrationsDirectory ) ) + ? managerConfig.migrationsDirectory + : "resources/database/migrations/" + + if ( arguments.verbose ) { + print.blackOnYellowLine( "cbmigrations info:" ) + print.line( config ).line() + } + + // ── List migration files from disk ──────────────────────────────────── + var resolvedPath = getCWD() & "/" & migrationsDir; + var diskFiles = listMigrationFiles( resolvedPath ); + var totalCount = diskFiles.len(); + + // ── Try to connect to DB for applied/pending info ───────────────────── + var dbAvailable = false; + var isTableInstalled = false; + var appliedCount = 0; + var pendingCount = totalCount; + var currentRevision = "Unknown"; + var allMigrations = []; + + try { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); + + isTableInstalled = variables.migrationService.isReady(); + allMigrations = variables.migrationService.findAll(); + dbAvailable = true; + + var appliedMigrations = allMigrations.filter( ( m ) => m.migrated ); + appliedCount = appliedMigrations.len(); + pendingCount = allMigrations.len() - appliedCount; + currentRevision = appliedMigrations.len() + ? appliedMigrations[ appliedMigrations.len() ].componentName + : "None"; + } catch ( any e ) { + // DB unreachable — build a disk-only migration list for display + dbAvailable = false; + allMigrations = diskFiles.map( ( file ) => { + return { + componentName : file.componentName, + timestamp : file.timestamp, + migrated : false, + canMigrateUp : false, + canMigrateDown : false + } + } ) + } + + // ── JSON output ─────────────────────────────────────────────────────── + if ( arguments.json ) { + print.line( + serializeJSON( { + "manager" : arguments.manager, + "directory" : migrationsDir, + "dbAvailable" : dbAvailable, + "tableInstalled" : isTableInstalled, + "applied" : appliedCount, + "pending" : pendingCount, + "total" : totalCount, + "currentRevision": currentRevision, + "migrations" : allMigrations.map( ( m ) => { + return { + "componentName" : m.componentName, + "timestamp" : isDate( m.timestamp ) + ? dateTimeFormat( m.timestamp, "yyyy-mm-dd HH:nn:ss" ) + : m.timestamp, + "migrated" : m.migrated ?: false, + "canMigrateUp" : m.canMigrateUp ?: false, + "canMigrateDown": m.canMigrateDown ?: false + }; + } ) + } ) + ); + return; + } + + // ── Pretty output ───────────────────────────────────────────────────── + print.boldLine( "Migration Status" ).line(); + + print.bold( "Manager: " ).line( arguments.manager ); + print.bold( "Directory: " ).line( migrationsDir ); + + if ( !dbAvailable ) { + print.bold( "Database: " ).yellowLine( "⚠ Unreachable" ); + print.yellowLine( "The database connection could not be established." ); + print.yellowLine( "Only filesystem information is shown below." ); + } else if ( isTableInstalled ) { + print.bold( "Table: " ).greenLine( "✓ Installed" ); + } else { + print.bold( "Table: " ).redLine( "✗ Not Installed" ); + } + + print.line(); + + // ── Counts ──────────────────────────────────────────────────────────── + print.bold( "Applied: " ).line( dbAvailable ? appliedCount : "?" ); + print.bold( "Pending: " ).yellowLine( dbAvailable ? pendingCount : "?" ); + print.bold( "Total: " ).line( totalCount ); + + print.line(); + print.bold( "Current Revision: " ); + if ( dbAvailable ) { + print.line( currentRevision ); + } else { + print.yellowLine( currentRevision ); + } + print.line(); + + // ── Migration Table ─────────────────────────────────────────────────── + if ( !totalCount ) { + print.yellowLine( "No migration files found." ); + return; + } + + if ( dbAvailable && !isTableInstalled ) { + print.yellowLine( "The migration tracking table has not been installed." ); + print.yellowLine( "Run 'migrate install' to create it, then re-run this command." ); + print.line(); + } + + // Column separators + var sep = repeatString( "─", 80 ); + + print.boldLine( sep ); + print.bold( "Status " ).bold( "Timestamp " ).boldLine( "Migration" ); + print.boldLine( sep ); + + for ( var m in allMigrations ) { + var tsFormatted = isDate( m.timestamp ) + ? dateTimeFormat( m.timestamp, "yyyy-mm-dd HH:nn:ss" ) + : m.timestamp; + + if ( !dbAvailable ) { + print.yellow( " ? " ); + } else if ( m.migrated ) { + print.green( " ✓ " ); + } else { + print.yellow( " ⏳ " ); + } + print.line( "#tsFormatted# #m.componentName#" ).toConsole(); + } + + print.boldLine( sep ); + print.line(); + + if ( !dbAvailable ) { + print.yellowLine( "Tip: Ensure your database is running and environment variables are configured." ); + } + } + + // ── Private Helpers ─────────────────────────────────────────────────────── + + /** + * Lists migration files from the given directory on disk, extracting + * component names and timestamps from filenames. No database is needed. + * + * @directory Absolute path to the migrations directory. + * + * @return Array of structs with keys: fileName, componentName, timestamp. + */ + private array function listMigrationFiles( required string directory ) { + if ( !directoryExists( arguments.directory ) ) { + return []; + } + + var files = directoryList( + arguments.directory, + true, + "array", + "*" + ); + + if ( !files.len() ) { + return []; + } + + return files + .filter( ( file ) => { + var ext = listLast( file, "." ); + return listFindNoCase( "cfc,bx", ext ); + } ) + .map( ( file ) => { + var fileName = getFileFromPath( file ); + var componentName = left( fileName, len( fileName ) - 4 ); + return { + fileName : fileName, + componentName : componentName, + timestamp : extractTimestampFromFileName( fileName ) + }; + } ) + .filter( ( file ) => isDate( file.timestamp ) ); + } + + /** + * Extracts a datetime from a migration filename prefix (e.g., + * "2022_11_01_192710_create_users_table.cfc" → {ts '2022-11-01 19:27:10'}). + * + * @fileName The migration file name (not full path). + * + * @return A date/time object, or an empty string if the prefix is invalid. + */ + private any function extractTimestampFromFileName( required string fileName ) { + try { + var timestampString = left( arguments.fileName, 17 ); + var timestampParts = listToArray( timestampString, "_" ); + return createDateTime( + timestampParts[ 1 ], + timestampParts[ 2 ], + timestampParts[ 3 ], + mid( timestampParts[ 4 ], 1, 2 ), + mid( timestampParts[ 4 ], 3, 2 ), + mid( timestampParts[ 4 ], 5, 2 ) + ); + } catch ( any e ) { + return ""; + } + } + +} diff --git a/commands/migrate/uninstall.cfc b/commands/migrate/uninstall.cfc index c933f6d..e075a3f 100644 --- a/commands/migrate/uninstall.cfc +++ b/commands/migrate/uninstall.cfc @@ -1,47 +1,72 @@ /** - * Uninstalls the cfmigrations table from your database. + * Uninstall the migrations tracking table from your database. * - * The cfmigrations table keeps track of the migrations ran against your database. - * Uninstall it when you are removing cfmigrations from your application. + * WARNING: Uninstalling will also run all your migrations DOWN before removing + * the tracking table. This means all applied migrations will be rolled back + * and all data managed by those migrations will be lost. + * + * Use this command when you are fully removing migrations from your application + * or want a completely clean slate. You will be asked to confirm before proceeding + * unless the --force flag is provided. + * + * {code:bash} + * ## Uninstall with confirmation prompt + * migrate uninstall + * + * ## Uninstall without confirmation + * migrate uninstall --force + * + * ## Uninstall a named manager + * migrate uninstall --manager=secondary + * + * ## Uninstall with verbose error output + * migrate uninstall --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @manager.hint The Migration Manager to use. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @verbose.hint If true, errors output a full stack trace. - * @force.hint If true, will not wait for confirmation to uninstall cfmigrations. + * @verbose If true, errors output a full stack trace. + * @force If true, will not wait for confirmation to uninstall cbmigrations. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ - function run( string manager = "default", boolean verbose = false, boolean force = false ) { - setup( arguments.manager ); + function run( + string manager = "default", + boolean verbose = false, + boolean force = false, + boolean installDrivers = true + ) { + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } pagePoolClear(); try { if ( !variables.migrationService.isReady() ) { - print.line( "No Migration table detected." ); + print.yellowLine( "📭 No Migration table detected." ); return; } if ( arguments.force || confirm( - "Uninstalling cfmigrations will also run all your migrations down. Are you sure you want to continue? [y/n]" + "Uninstalling cbmigrations will also run all your migrations down. Are you sure you want to continue? [y/n]" ) ) { variables.migrationService.uninstall(); - print.line( "Migration table uninstalled!" ).line(); + print.greenLine( "🗑️ Migration table uninstalled!" ).line(); } else { - print.line( "Aborting uninstall process." ); + print.yellowLine( "⏭️ Aborting uninstall process." ); } } catch ( any e ) { if ( arguments.verbose ) { if ( structKeyExists( e, "Sql" ) ) { - print.whiteOnRedLine( "Error when trying to run #currentlyRunningMigration.componentName#:" ); + print.whiteOnRedLine( "❌ Error when trying to uninstall #currentlyRunningMigration.componentName#:" ); print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); } rethrow; diff --git a/commands/migrate/up.cfc b/commands/migrate/up.cfc index 4105f57..1dfaa18 100644 --- a/commands/migrate/up.cfc +++ b/commands/migrate/up.cfc @@ -1,16 +1,43 @@ /** * Apply one or all pending migrations against your database. + * + * Migrations are applied in chronological order based on their timestamp prefix. + * The migrations table must be installed first via `migrate install`. + * + * {code:bash} + * ## Run all pending migrations + * migrate up + * + * ## Apply only the next pending migration + * migrate up --once + * + * ## Preview SQL without executing (dry run) + * migrate up --pretend + * + * ## Save the pretend SQL output to a file + * migrate up --pretend --file=schema.sql + * + * ## Run migrations and then seed the database + * migrate up --seed + * + * ## Run migrations for a named manager + * migrate up --manager=secondary + * + * ## Run with verbose error output + * migrate up --verbose + * {code} */ component extends="commandbox-migrations.models.BaseMigrationCommand" { /** - * @manager.hint The Migration Manager to use. + * @manager The Migration Manager to use. * @manager.optionsUDF completeManagers - * @seed.hint If true, runs all seeders for the manager after creating a fresh database. - * @once.hint Only apply a single migration. - * @verbose.hint If true, errors output a full stack trace. - * @pretend.hint If true, only pretends to run the query. The SQL that would have been run is printed to the console. - * @file.hint If provided, outputs the SQL that would have been run to the file. Only applies when running `pretend`. + * @seed If true, runs all seeders for the manager after creating a fresh database. + * @once Only apply a single migration. + * @verbose If true, errors output a full stack trace. + * @pretend If true, only pretends to run the query. The SQL that would have been run is printed to the console. + * @file If provided, outputs the SQL that would have been run to the file. Only applies when running `pretend`. + * @installDrivers If true, auto-install the BoxLang JDBC driver module. Default: true. */ function run( string manager = "default", @@ -18,13 +45,14 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { boolean once = false, boolean verbose = false, boolean pretend = false, - string file + string file, + boolean installDrivers = true ) { - setup( arguments.manager ); + setup( manager: arguments.manager, installDrivers = arguments.installDrivers ); if ( arguments.verbose ) { - print.blackOnYellowLine( "cfmigrations info:" ); - print.line( getCFMigrationsInfo() ).line(); + print.blackOnYellowLine( "cbmigrations info:" ); + print.line( getMigrationsInfo() ).line(); } pagePoolClear(); @@ -35,19 +63,19 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { checkForInstalledMigrationTable(); if ( !migrationService.hasMigrationsToRun( "up" ) ) { - print.line().yellowLine( "No migrations to run." ).line(); + print.line().yellowLine( "📭 No migrations to run." ).line(); } else if ( once ) { migrationService.runNextMigration( direction = "up", preProcessHook = ( migration ) => { currentlyRunningMigration = migration; - print.yellow( "Migrating: " ).line( migration.componentName ).toConsole(); + print.yellow( "⏫ Migrating: " ).line( migration.componentName ).toConsole(); }, postProcessHook = ( migration, schema, qb ) => { if (!pretend) { - print.green( "Migrated: " ).line( migration.componentName ).toConsole(); + print.green( "✅ Migrated: " ).line( migration.componentName ).toConsole(); } else { - print.green( "Pretended to migrate: " ).line( migration.componentName ).toConsole(); + print.green( "🧪 Pretended to migrate: " ).line( migration.componentName ).toConsole(); print.line(); for ( var q in schema.getQueryLog() ) { var inlineSql = qb.getUtils().replaceBindings( q.sql, q.bindings, true ); @@ -70,13 +98,13 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { direction = "up", preProcessHook = ( migration ) => { currentlyRunningMigration = migration; - print.yellow( "Migrating: " ).line( migration.componentName ).toConsole(); + print.yellow( "⏫ Migrating: " ).line( migration.componentName ).toConsole(); }, postProcessHook = ( migration, schema, qb ) => { if (!pretend) { - print.green( "Migrated: " ).line( migration.componentName ).toConsole(); + print.green( "✅ Migrated: " ).line( migration.componentName ).toConsole(); } else { - print.green( "Pretended to migrate: " ).line( migration.componentName ).toConsole(); + print.green( "🧪 Pretended to migrate: " ).line( migration.componentName ).toConsole(); print.line(); for ( var q in schema.getQueryLog() ) { var inlineSql = qb.getUtils().replaceBindings( q.sql, q.bindings, true ); @@ -98,7 +126,7 @@ component extends="commandbox-migrations.models.BaseMigrationCommand" { } catch ( any e ) { if ( verbose ) { if ( structKeyExists( e, "Sql" ) ) { - print.whiteOnRedLine( "Error when trying to run #currentlyRunningMigration.componentName#:" ); + print.whiteOnRedLine( "❌ Error when trying to run #currentlyRunningMigration.componentName#:" ); print.line( variables.sqlHighlighter.highlight( variables.sqlFormatter.format( e.Sql ) ).toAnsi() ); } rethrow; diff --git a/models/BaseMigrationCommand.cfc b/models/BaseMigrationCommand.cfc index 1c52892..882b3bf 100644 --- a/models/BaseMigrationCommand.cfc +++ b/models/BaseMigrationCommand.cfc @@ -1,3 +1,8 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + */ component { property name="fileSystemUtil" inject="FileSystem"; @@ -6,9 +11,32 @@ component { property name="sqlHighlighter" inject="sqlHighlighter"; property name="systemSettings" inject="SystemSettings"; property name="sqlFormatter" inject="Formatter@sqlFormatter"; + property name="serverService" inject="ServerService"; + property name="moduleConfig" inject="box:moduleconfig:commandbox-migrations"; - function setup( required string manager, boolean setupDatasource = true ) { - var config = getCFMigrationsInfo(); + /** + * Initialize the BaseCommand + */ + function init(){ + return this; + } + + /** + * Resolves the named manager's settings from the migrations config and + * initializes `variables.migrationService` from them. + * + * @manager The Migration Manager to use. + * @setupDatasource If true, registers an on-the-fly application datasource from `connectionInfo`. + * @installDrivers If true (default) and a BoxLang runtime is active, auto-installs the matching + * `bx-*` JDBC driver module from ForgeBox into `boxlang_modules/`. Pass false + * to skip auto-install (e.g. `--noDriverInstall` from the CLI). + */ + function setup( + required string manager, + boolean setupDatasource = true, + boolean installDrivers = true + ) { + var config = getMigrationsInfo(); if ( !config.keyExists( arguments.manager ) ) { error( "No manager found named [#arguments.manager#]. Available managers are: #config.keyList( ", " )#" ); } @@ -25,13 +53,16 @@ component { if ( !directoryExists( expandPath( settings.seedsDirectory ) ) ) { directoryCreate( expandPath( settings.seedsDirectory ) ); - print.line( "Created seeds directory" ) + print.greenLine( "📁 Created seeds directory" ) } } if ( arguments.setupDatasource ) { param settings.properties = {}; if ( settings.properties.keyExists( "connectionInfo" ) ) { - var datasource = installDatasource( settings.properties.connectionInfo ); + var datasource = installDatasource( + connectionInfo: settings.properties.connectionInfo, + installDrivers: arguments.installDrivers + ); settings.properties.delete( "connectionInfo" ); settings.properties[ "datasource" ] = datasource; } @@ -42,14 +73,42 @@ component { ); } - function installDatasource( required struct connectionInfo, string datasourceName = "cfmigrations" ) { + /** + * Registers an on-the-fly application datasource from the given connection info + * and sets it as the application default, returning the datasource name. + * + * When running inside a BoxLang runtime AND `installDrivers` is true, the matching + * `bx-*` JDBC driver module is auto-installed (if missing) and loaded from + * `boxlang_modules/` so the connection can be established. Set `installDrivers` to + * false to skip the auto-install (e.g. CI scripts using `--noDriverInstall`). + * + * @connectionInfo The connection info struct to register as a datasource. + * @datasourceName The datasource name to register under (default: `cbmigrations`). + * @installDrivers If true, auto-installs/loads the matching BoxLang JDBC driver. + */ + function installDatasource( + required struct connectionInfo, + string datasourceName = "cbmigrations", + boolean installDrivers = true + ) { + if ( arguments.installDrivers && isBoxLang() ) { + var slug = detectBoxLangDriverSlug( arguments.connectionInfo ); + if ( len( slug ) ) { + ensureBoxLangDriver( slug ); + } + } + var datasources = getApplicationSettings().datasources ?: {}; - datasources[ "cfmigrations" ] = arguments.connectionInfo; + datasources[ arguments.datasourceName ] = arguments.connectionInfo; application action='update' datasources=datasources; application action='update' datasource='#arguments.datasourceName#'; return arguments.datasourceName; } + /** + * Updates the migration service's migrations directory to the given path, + * resolved and made relative to the current working directory. + */ public void function setMigrationPath( required migrationsDirectory ) { var relativePath = fileSystemUtil.makePathRelative( fileSystemUtil.resolvePath( migrationsDirectory ) @@ -57,10 +116,17 @@ component { migrationService.setMigrationsDirectory( relativePath ); } + /** + * Returns the migration service's currently configured migrations directory. + */ public string function getMigrationPath () { return migrationService.getMigrationsDirectory(); } + /** + * Prompts the user to install the migration table if it hasn't been installed yet, + * aborting the command if they decline. + */ private void function checkForInstalledMigrationTable() { if ( ! variables.migrationService.isReady() ) { if ( confirm( "Migration table not installed. Do you want to install it now? [y\n]" ) ) { @@ -71,72 +137,217 @@ component { } } - private struct function getCFMigrationsInfo() { - var cfmigrationsInfoType = "boxJSON"; + /** + * Detects whether the active runtime is BoxLang (either inside a live BoxLang + * server engine, or via box.json's `language` key). Used to gate driver + * auto-install behavior. + */ + private boolean function isBoxLang() { + return server.keyExists( "boxlang") + } - var directory = getCWD(); + /** + * Resolves the BoxLang JDBC driver ForgeBox slug from a connection info struct. + * Resolution order: + * 1. `driver` key — the explicit BoxLang driver name (e.g. `bx-mysql`). + * 2. `type` key — Lucee-style type (`mysql`, `postgresql`, `mssql`, etc.). + * 3. JDBC `connectionString` URL prefix (`jdbc:mysql:`, `jdbc:postgresql:`, …). + * + * Returns the ForgeBox slug (e.g. `bx-postgresql`) or an empty string if no + * driver could be determined. + */ + string function detectBoxLangDriverSlug( required struct connectionInfo ) { + var typeMap = { + "mysql" : "bx-mysql", + "mariadb" : "bx-mariadb", + "postgresql" : "bx-postgresql", + "pgsql" : "bx-postgresql", + "mssql" : "bx-mssql", + "sqlserver" : "bx-mssql", + "oracle" : "bx-oracle", + "oracledb" : "bx-oracle", + "sqlite" : "bx-sqlite", + "derby" : "bx-derby", + "h2" : "bx-hypersql", + "hypersql" : "bx-hypersql", + "hsqldb" : "bx-hypersql" + } - // Check and see if a .cfmigrations.json file exists - if ( fileExists( "#directory#/.cfmigrations.json" ) ) { - var cfmigrationsInfo = deserializeJSON( fileRead( "#directory#/.cfmigrations.json" ) ); - variables.systemSettings.expandDeepSystemSettings( cfmigrationsInfo ); - cfmigrationsInfoType = "cfmigrations"; - return cfmigrationsInfo; + // 1. Explicit driver key wins + if ( arguments.connectionInfo.keyExists( "driver" ) && typeMap.keyExists( arguments.connectionInfo.driver) ) { + return typeMap[ arguments.connectionInfo.driver ] } - // Check and see if box.json exists - if( !packageService.isPackage( directory ) ) { - return error( "File [#packageService.getDescriptorPath( directory )#] does not exist." ); + // 2. Lucee-style `type` key (case-insensitive) + if ( arguments.connectionInfo.keyExists( "type" ) && typeMap.keyExists( arguments.connectionInfo.type )) { + return typeMap[ arguments.connectionInfo.type ] + } + + // 3. Parse a JDBC URL prefix from `connectionString`. + if ( arguments.connectionInfo.keyExists( "connectionString" ) && len( arguments.connectionInfo.connectionString ) ) { + var connectionString = trim( arguments.connectionInfo.connectionString ) + for ( var thisType in typeMap ) { + if ( findNoCase( "jdbc:#thisType#", connectionString ) == 1 ) { + return typeMap[ thisType ] + } + } } - print.boldUnderscoredYellowLine( "Storing cfmigrations information in box.json has been deprecated in v4 and will be removed in v5." ); - print.line( "Please refer to the migration guide at https://github.com/commandbox-modules/commandbox-migrations to upgrade." ); - print.line(); + return "" + } - var boxJSON = packageService.readPackageDescriptor( directory ); - var boxJSONMigrationsInfo = JSONService.show( boxJSON, "cfmigrations", {} ); + /** + * Ensures the given BoxLang JDBC driver module is present in `boxlang_modules/` + * and registered with the active BoxLang runtime. Idempotent — safe to call + * repeatedly. Failures are logged but never break the calling command. + * + * @slug The ForgeBox slug of the driver (e.g. `bx-mysql`). + */ + void function ensureBoxLangDriver( required string slug ) { + var modulesDir = variables.moduleConfig.path & "/boxlang_modules" + var targetModuleDir = modulesDir & "/" & arguments.slug - if ( boxJSONMigrationsInfo.keyExists( "managers" ) ) { - var cfmigrationsInfo = boxJSONMigrationsInfo; - variables.systemSettings.expandDeepSystemSettings( cfmigrationsInfo ); - cfmigrationsInfoType = "cfmigrations"; - return cfmigrationsInfo; + if ( !directoryExists( targetModuleDir ) ) { + variables.print + .yellowLine( "⬇️ Auto-installing BoxLang JDBC driver [#arguments.slug#]…" ) + .toConsole() + var installed = variables.packageService.installPackage( + ID = arguments.slug, + directory = modulesDir + ) + if ( !installed ) { + variables.print + .yellowLine( "⚠️ Driver [#arguments.slug#] could not be auto-installed. Continuing without it." ) + .toConsole() + return + } } - print.boldUnderscoredYellowLine( "The format of the migrations configuration has changed in v4." ); - print.line( "We will convert your configuration to the new format. This auto-conversion will be dropped in v5." ); - print.line( "Please refer to the migration guide at https://github.com/commandbox-modules/commandbox-migrations to upgrade." ); - print.line(); + loadBoxLangDrivers(); + } + + /** + * Loads any modules found under `boxlang_modules/` into the active BoxLang + * runtime. Calling `loadModules` on the parent directory is safe and + * idempotent — already-registered modules are skipped. + */ + void function loadBoxLangDrivers() { + var modulesDir = getCWD() & "/boxlang_modules" + if ( !directoryExists( modulesDir ) ) { + return; + } - param boxJSONMigrationsInfo.migrationsDirectory = "resources/database/migrations"; - param boxJSONMigrationsInfo.defaultGrammar = "AutoDiscover@qb"; + getBoxRuntime() + .getModuleService() + .loadModules( + createObject( "java", "java.nio.file.Paths" ).get( modulesDir ) + ) - var properties = { - "connectionInfo": boxJSONMigrationsInfo.connectionInfo, - "defaultGrammar": boxJSONMigrationsInfo.defaultGrammar - }; - if ( boxJSONMigrationsInfo.keyExists( "schema" ) ) { - properties[ "schema" ] = boxJSONMigrationsInfo.schema; + print.greenLine( "⚡ BoxLang driver module(s) loaded from [#modulesDir#]." ) + } + + /** + * Returns the path to the first migrations config file found in the given directory, + * checking `.cbmigrations.json` before `.cfmigrations.json`. If only + * `.cfmigrations.json` exists, the user is prompted to rename it to the new + * `.cbmigrations.json` name. + */ + private string function findMigrationsConfigPath( required string directory ) { + // Check for the modern config file first + var cbmigrationsPath = "#arguments.directory#/.cbmigrations.json" + if ( fileExists( cbmigrationsPath ) ) { + return cbmigrationsPath } - var cfmigrationsInfo = { - "default": { - "manager": "cfmigrations.models.QBMigrationManager", - "migrationsDirectory": boxJSONMigrationsInfo.migrationsDirectory, - "properties": properties + // Check for the legacy config file + var cfmigrationsPath = "#arguments.directory#/.cfmigrations.json" + if ( fileExists( cfmigrationsPath ) ) { + return cfmigrationsPath + } + + return "" + } + + /** + * Detects whether the given directory should be treated as a BoxLang project, + * based on the running CommandBox server's engine or box.json's `language` key. + * + * @directory The directory to check for box.json (usually the current working directory). + * + * @return True if this is a BoxLang project, false otherwise. + */ + private boolean function isBoxLangProject( required string directory ) { + // Detect if the running CommandBox server is BoxLang. + var serverInfo = variables.serverService.resolveServerDetails( {} ).serverInfo; + if ( serverInfo.keyExists( "cfengine" ) && serverInfo.cfengine contains "boxlang" ) { + return true + } + + // Detect via box.json's language key. + if ( packageService.isPackage( arguments.directory ) ) { + var boxJSON = packageService.readPackageDescriptor( arguments.directory ); + if ( boxJSON.keyExists( "language" ) && boxJSON.language == "boxlang" ) { + return true } - }; + } + + return false + } + + /** + * Loads the migrations config: from `.cbmigrations.json`/`.cfmigrations.json` if present, + * otherwise falls back to the deprecated `cfmigrations` key in box.json (auto-converting + * the legacy pre-v4 format if needed). + */ + private struct function getMigrationsInfo() { + var migrationsInfoType = "boxJSON" + var directory = getCWD() + + // Check and see if a .cbmigrations.json or .cfmigrations.json file exists + var configPath = findMigrationsConfigPath( directory ) + if ( len( configPath ) ) { + var migrationsInfo = deserializeJSON( fileRead( configPath ) ) + variables.systemSettings.expandDeepSystemSettings( migrationsInfo ) + migrationsInfoType = "cfmigrations" + return migrationsInfo + } + + // Check and see if box.json exists + if( !packageService.isPackage( directory ) ) { + return error( "File [#packageService.getDescriptorPath( directory )#] does not exist." ) + } + + var boxJSON = packageService.readPackageDescriptor( directory ); + var boxJSONMigrationsInfo = JSONService.show( boxJSON, "cfmigrations", {} ); + + if ( boxJSONMigrationsInfo.keyExists( "managers" ) ) { + print.boldUnderscoredYellowLine( "📦 Storing cfmigrations information in box.json has been deprecated in v4 and will be removed in v5." ); + print.line( "Please refer to the migration guide at https://github.com/commandbox-modules/commandbox-migrations to upgrade." ) + print.line() + + var migrationsInfo = boxJSONMigrationsInfo + variables.systemSettings.expandDeepSystemSettings( migrationsInfo ) + migrationsInfoType = "cfmigrations" + return migrationsInfo + } + + throw( + type: "NoMigrationsConfigFound", + message: "No migrations config found. Please create a .cbmigrations.json file found in the project root, or run migrate init to create one." + ) - variables.systemSettings.expandDeepSystemSettings( cfmigrationsInfo ); - return cfmigrationsInfo; } - private string function getCFMigrationsType() { + /** + * Returns "cbmigrations" if a dedicated config file or v4-style box.json config is found, + * or "boxJSON" if only the legacy pre-v4 box.json format is present. + */ + private string function getMigrationsConfigType() { var directory = getCWD(); - // Check and see if a .cfmigrations.json file exists - if ( fileExists( "#directory#/.cfmigrations.json" ) ) { - return "cfmigrations"; + // Check and see if a .cbmigrations.json or .cbmigrations.json file exists + if ( len( findMigrationsConfigPath( directory ) ) ) { + return "cbmigrations"; } // Check and see if box.json exists @@ -148,23 +359,30 @@ component { var boxJSONMigrationsInfo = JSONService.show( boxJSON, "cfmigrations", {} ); if ( boxJSONMigrationsInfo.keyExists( "managers" ) ) { - return "cfmigrations"; + return "cbmigrations"; } return "boxJSON"; } + /** + * Tab-completion options for the `manager` argument, listing configured manager + * names that start with what's been typed so far. + */ function completeManagers( string paramSoFar ) { - var type = getCFMigrationsType(); + var type = getMigrationsConfigType(); if ( type == "boxJSON" ) { return []; } - return getCFMigrationsInfo().keyArray() + return getMigrationsInfo().keyArray() .filter( ( manager ) => startsWith( manager, paramSoFar ) ) .map( ( manager ) => ( { "name": manager, "group": "Managers" } ) ); } - private string function startsWith( required string word, required string substring ) { + /** + * Returns true if `word` starts with `substring` (or `substring` is empty). + */ + private boolean function startsWith( required string word, required string substring ) { if ( len( arguments.substring ) == 0 ) { return true; } diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..e8b709d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,299 @@ +{ + "version": 1, + "skills": { + "boxlang-application-descriptor": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/application-descriptor/SKILL.md", + "computedHash": "f060db9e4cfd932f4977f70c079555362aadfcbdd2fca250a04d7f8fb471207a" + }, + "boxlang-async-programming": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/async-programming/SKILL.md", + "computedHash": "51b310a5f862faf03ff930b8854566ce697ef5706014ea0b8d41fd678f9aa6d5" + }, + "boxlang-best-practices": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/best-practices/SKILL.md", + "computedHash": "be3060bb9d86e46c36589d70b83a178737bba80e69348de8846301f6160290b5" + }, + "boxlang-cfml-migration": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/cfml-migration/SKILL.md", + "computedHash": "a284466dc5cc392747b8e46a206ee162acc14bfe3e3c3b25200cecc07d1129ea" + }, + "boxlang-classes-and-oop": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/classes-and-oop/SKILL.md", + "computedHash": "17477293bf20faebb024944240c91a838c169d1a705ab0f4ccfa9c04fd9897c0" + }, + "boxlang-code-documenter": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/code-documenter/SKILL.md", + "computedHash": "ac06957c446282e603c5ba566c893618983a3dfea7f49df6986d5048824aef17" + }, + "boxlang-code-reviewer": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/code-reviewer/SKILL.md", + "computedHash": "75b1a6891e8e4c5f73f5251956f6ef7bfaf497289cf13cdbad277e7a5bf8d55d" + }, + "boxlang-configuration": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/configuration/SKILL.md", + "computedHash": "62ea714d8d806324b56ed0d656e9f21663e756ede9e52e53c120962f924e4eb5" + }, + "boxlang-database-access": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/database-access/SKILL.md", + "computedHash": "44e26ffcf4dcd633eedd4d08aaa1dc0d1ad5aac700d6798b324e7b3e1c8688f0" + }, + "boxlang-docbox": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/docbox/SKILL.md", + "computedHash": "f68d2cdd4e2ed9cf23005c466bb50b119794f449c61eeb7bb32469c03a3e7fe3" + }, + "boxlang-file-handling": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/file-handling/SKILL.md", + "computedHash": "134f2e5c3a1cd7fb14f971cc2580f7c0fa1046e85f986232314adefd652f4f7c" + }, + "boxlang-file-watchers": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/file-watchers/SKILL.md", + "computedHash": "db6db2a78806f93e6a58d24895ea9373a0dbbe29809479dd9dc50209cf9f9819" + }, + "boxlang-functional-programming": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/functional-programming/SKILL.md", + "computedHash": "d8e4d8b2f4f3f6bd1cb43cd557945c0e7a6d68340b962f2bcc4d2a5c2012a546" + }, + "boxlang-interceptors": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/interceptors/SKILL.md", + "computedHash": "18e163de09ae7cfb0ab0f2b4d74d97c58f5ace239630d57b8505909373f5bc84" + }, + "boxlang-java-integration": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/java-integration/SKILL.md", + "computedHash": "f0e6a9dd347c4cbca99aae570fd2ddaa46751d0175767f3cc08d57b66a8ba10b" + }, + "boxlang-language-fundamentals": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/language-fundamentals/SKILL.md", + "computedHash": "1791856caa11bad9436ea0229eaa45f1505a6d26f030f5c244dfbcd2dd7fe4da" + }, + "boxlang-modules-and-packages": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/modules-and-packages/SKILL.md", + "computedHash": "d2291df901e39f82c9bc1225ceb04a0c2046b5ec06a596e4a136942ec85b2910" + }, + "boxlang-runtime-cli-scripting": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/runtime-cli-scripting/SKILL.md", + "computedHash": "e8a3704ba54ece31d36ceb52744a79618ba8be436018ef30aaf673291e20de96" + }, + "boxlang-runtime-commandbox": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/runtime-commandbox/SKILL.md", + "computedHash": "8744cc111e9146cf929e0d2f5c4344f871a06ebfe69e8b7d5cb532b9c17274e2" + }, + "boxlang-scheduled-tasks": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/scheduled-tasks/SKILL.md", + "computedHash": "534448e61aacce705de4065549cfccd9cf8bb1c552330fddd3b40b200960ab58" + }, + "boxlang-security": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/security/SKILL.md", + "computedHash": "bcf72fd5f5d27c40efef3e6f4ee33017497f0b6bc54e35ae59b3a485b824af0f" + }, + "boxlang-templating": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/templating/SKILL.md", + "computedHash": "92712aa9582312ebd5ed430d8966aa45a47996cb3ec9679cfb401a88085eae63" + }, + "boxlang-testing": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/testing/SKILL.md", + "computedHash": "5062447bfb00f97d62536e8f83e78f5e0e05ea9b01f1ab542b9d29c6a4a7224c" + }, + "boxlang-web-development": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/web-development/SKILL.md", + "computedHash": "365a7dea08d0aa06af2dec134ef5a6c67fe4cc622e9856e2a10426a65ec90de5" + }, + "boxlang-zip": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/zip/SKILL.md", + "computedHash": "06f346e32aacdccfd501222341cca88b3226e3703123e6e8053495e058bc9f0a" + }, + "commandbox-config-settings": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-config-settings/SKILL.md", + "computedHash": "19dfd2dbd842e0c4fa428e5c7921fd36c996b56d94345daa308be32293c12ab2" + }, + "commandbox-deploying": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-deploying/SKILL.md", + "computedHash": "81ed1fdd292fb9ad29ed7daa1dba9f7daa722bd684d9b2cf7fc96faa7387cefd" + }, + "commandbox-developing": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-developing/SKILL.md", + "computedHash": "c18b0d4ceaf9c03edb5ed9a61b59d1a696014744126929017fc2856c90b7c709" + }, + "commandbox-embedded-server": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-embedded-server/SKILL.md", + "computedHash": "ffb7bc1079ab5fc3361bf2a64427030ecef5ae4c74ef0e9bfecb6c210b14200b" + }, + "commandbox-package-management": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-package-management/SKILL.md", + "computedHash": "63b73016c804250f9e05104d4c87015071b0bbbe84af268369d1d3243f9c4548" + }, + "commandbox-setup": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-setup/SKILL.md", + "computedHash": "8ae2ee6f93d668e4a11d9bd57d72d3b9f68f1cb3f9e2d780b6f2675463371bf3" + }, + "commandbox-task-runners": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-task-runners/SKILL.md", + "computedHash": "a32c3fe1969f261e789e732ca68e4c5e2e9383b35d0be20ed1fba473ea95feef" + }, + "commandbox-testing": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-testing/SKILL.md", + "computedHash": "15dfb0ca0d64edfd0ec0344df2f9b3494a8a15a666a464927148f43deaa5b585" + }, + "commandbox-usage": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "commandbox/commandbox-usage/SKILL.md", + "computedHash": "db689cb9f7f895a202ecae3986f24557ff0e57d4c614da6919678204e995dd87" + }, + "github-action-authoring": { + "source": "ortus-solutions/skills", + "sourceType": "github", + "skillPath": "github-action-authoring/SKILL.md", + "computedHash": "2a68bac1399c4a54c88fd7d2596b1f3e0cd68461b2dca33335c2210504c9dd8b" + }, + "java-expert": { + "source": "ortus-solutions/skills", + "sourceType": "github", + "skillPath": "java-expert/SKILL.md", + "computedHash": "f622426ac4233ec79888d7c2b87c966efa41fed4dd06fbe24f77b86c3da79079" + }, + "junit-expert": { + "source": "ortus-solutions/skills", + "sourceType": "github", + "skillPath": "junit-expert/SKILL.md", + "computedHash": "5ec181a33d4888873dbbc3fc56308fc4ca76dd630e21022284a13f07f092f6c9" + }, + "ortus-coding-standards": { + "source": "ortus-boxlang/skills", + "sourceType": "github", + "skillPath": "boxlang-developer/ortus-coding-standards/SKILL.md", + "computedHash": "01480f77e70756f2029bf159a79acf3ab079f0159ea7c4eaff499a28fcacf6b5" + }, + "testbox-assertions": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/assertions/SKILL.md", + "computedHash": "694ca3ce806c087739e3bff4aaa14056be7a5401a8744b43f97ed9e9f89d8450" + }, + "testbox-bdd": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/bdd/SKILL.md", + "computedHash": "b74bf8329dfa96959df21b0737e78499269fa45256978d047643e2a258de403d" + }, + "testbox-cbmockdata": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/cbmockdata/SKILL.md", + "computedHash": "899b465c01b9257720f6d04834ffc567a8ad4381ece2f807ec431ae887d0a132" + }, + "testbox-expectations": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/expectations/SKILL.md", + "computedHash": "b45d34d7a74fbed11455f2e1906b5d1477a33f313900cc8066d241ad9853b5f8" + }, + "testbox-listeners": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/listeners/SKILL.md", + "computedHash": "ca3d8a2120e5362592827cf22e5712c8f64a79d3da9392a829ffea6f78e9a221" + }, + "testbox-mockbox": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/mockbox/SKILL.md", + "computedHash": "2aeb189901f43e79249efccd49eb0771c42438f4248de3d631f4b3da7b445a50" + }, + "testbox-reporters": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/reporters/SKILL.md", + "computedHash": "89da95096b7a7c4f2f2da8a18a2d701056df0321bac5962f11fa48167541ec1b" + }, + "testbox-runners": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/runners/SKILL.md", + "computedHash": "048a646907313fae5b94ddc6ea3d88019c0873570b7c1c89efa885ec68ddf305" + }, + "testbox-unit-xunit": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/unit/SKILL.md", + "computedHash": "a8b371d1d7dab879ac85120bdf83e2972a5a6b1d99efb4bd39d92fa025fdcd13" + }, + "testing-coverage": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/testing-coverage/SKILL.md", + "computedHash": "912f7ee7a7f2e820a646cb0913257d0b086c1f49ed3033e9007136920c7f5b9a" + }, + "testing-fixtures": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/testing-fixtures/SKILL.md", + "computedHash": "4393c328b30d2201a5419f8ea4aee8186e451cb90ea196d8bf77ffd47355e2f2" + } + } +} diff --git a/templates/MigrationBX.txt b/templates/MigrationBX.txt new file mode 100644 index 0000000..a18b475 --- /dev/null +++ b/templates/MigrationBX.txt @@ -0,0 +1,11 @@ +class { + + function up( schema, qb ) { + + } + + function down( schema, qb ) { + + } + +} diff --git a/templates/config.txt b/templates/config.txt index 115a016..103e92f 100644 --- a/templates/config.txt +++ b/templates/config.txt @@ -6,14 +6,16 @@ "properties": { "defaultGrammar": "AutoDiscover@qb", "schema": "${DB_SCHEMA}", - "migrationsTable": "cfmigrations", + "migrationsTable": "cbmigrations", "connectionInfo": { "type": "${DB_DRIVER}", "database": "${DB_DATABASE}", "host": "${DB_HOST}", - "port": ${DB_PORT}, + "port": "${DB_PORT}", "username": "${DB_USER}", - "password": "${DB_PASSWORD}" + "password": "${DB_PASSWORD}", + "bundleName": "${DB_BUNDLENAME}", + "bundleVersion": "${DB_BUNDLEVERSION}" } } } diff --git a/templates/seedBX.txt b/templates/seedBX.txt new file mode 100644 index 0000000..590fdff --- /dev/null +++ b/templates/seedBX.txt @@ -0,0 +1,7 @@ +class { + + function run( qb, mockdata ) { + + } + +} diff --git a/tests/Runner.cfc b/tests/Runner.cfc new file mode 100644 index 0000000..a6bb8e2 --- /dev/null +++ b/tests/Runner.cfc @@ -0,0 +1,49 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * TestBox Test Runner for commandbox-migrations + * + * Usage: + * box run-script test + * box task run taskFile=tests/Runner.cfc + */ +component { + + /** + * Main test runner + */ + function run() { + var projectRoot = resolvePath( "../" ) + var testsDir = projectRoot & "tests/" + + // Create filesystem mappings for the test specs to access the command and template files + variables.fileSystemUtil.createMapping( "/testbox", projectRoot & "testbox" ) + variables.fileSystemUtil.createMapping( "/tests", testsDir ) + variables.fileSystemUtil.createMapping( "/commandbox-migrations", projectRoot ) + + // Seed the test specs with the project root so they can locate the command files and templates + request.commandboxMigrationsProjectRoot = projectRoot + + // Recurse = true so any nested spec directories are picked up automatically + var tb = new testbox.system.TestBox( + directory = { "mapping" : "tests.specs", "recurse" : true }, + reporter = "text" + ) + + print.line( tb.run() ).toConsole() + + var testResult = tb.getResult() + var totalFail = testResult.getTotalFail() + var totalError = testResult.getTotalError() + + if ( testResult.getTotalSpecs() == 0 ) { + error( "No test specs were executed" ) + } + + if ( totalFail > 0 || totalError != 0 ) { + error( "Test suite failed with #totalFail# failures and #totalError# errors" ) + } + } + +} diff --git a/tests/resources/fixtures/multi_manager_cbmigrations.json b/tests/resources/fixtures/multi_manager_cbmigrations.json new file mode 100644 index 0000000..2e9d5f4 --- /dev/null +++ b/tests/resources/fixtures/multi_manager_cbmigrations.json @@ -0,0 +1,34 @@ +{ + "default": { + "manager": "cfmigrations.models.QBMigrationManager", + "migrationsDirectory": "resources/database/migrations/", + "seedsDirectory": "resources/database/seeds/", + "properties": { + "defaultGrammar": "AutoDiscover@qb", + "connectionInfo": { + "type": "mysql", + "database": "primarydb", + "host": "127.0.0.1", + "port": 3306, + "username": "root", + "password": "" + } + } + }, + "secondary": { + "manager": "cfmigrations.models.QBMigrationManager", + "migrationsDirectory": "resources/database/secondary-migrations/", + "seedsDirectory": "resources/database/secondary-seeds/", + "properties": { + "defaultGrammar": "AutoDiscover@qb", + "connectionInfo": { + "type": "postgresql", + "database": "secondarydb", + "host": "127.0.0.1", + "port": 5432, + "username": "postgres", + "password": "" + } + } + } +} diff --git a/tests/resources/fixtures/valid_cbmigrations.json b/tests/resources/fixtures/valid_cbmigrations.json new file mode 100644 index 0000000..f1d62e8 --- /dev/null +++ b/tests/resources/fixtures/valid_cbmigrations.json @@ -0,0 +1,19 @@ +{ + "default": { + "manager": "cfmigrations.models.QBMigrationManager", + "migrationsDirectory": "resources/database/migrations/", + "seedsDirectory": "resources/database/seeds/", + "properties": { + "defaultGrammar": "AutoDiscover@qb", + "connectionInfo": { + "type": "mysql", + "database": "testdb", + "host": "127.0.0.1", + "port": 3306, + "username": "root", + "password": "", + "class": "com.mysql.cj.jdbc.Driver" + } + } + } +} diff --git a/tests/specs/BaseMigrationCommandTest.cfc b/tests/specs/BaseMigrationCommandTest.cfc new file mode 100644 index 0000000..ae9adb5 --- /dev/null +++ b/tests/specs/BaseMigrationCommandTest.cfc @@ -0,0 +1,242 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Tests for the commandbox-migrations configuration templates and fixtures. + * These tests validate the template files, generated fixtures, and expected + * configuration structures without requiring a live database or running server. + */ +component extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.projectRoot = request.commandboxMigrationsProjectRoot; + } + + function run() { + + describe( "Migration Config Templates", () => { + + describe( ".cbmigrations.json template", () => { + + it( "should exist as config.txt", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + expect( fileExists( templatePath ) ).toBeTrue(); + }); + + it( "should contain valid JSON", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var templateContent = replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ); + var config = deserializeJSON( templateContent ); + expect( isStruct( config ) ).toBeTrue(); + }); + + it( "should contain a default manager entry", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var config = deserializeJSON( replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ) ); + expect( config ).toHaveKey( "default" ); + }); + + it( "should use QBMigrationManager as the default manager", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var config = deserializeJSON( replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ) ); + expect( config.default ).toHaveKey( "manager" ); + expect( config.default.manager ).toInclude( "QBMigrationManager" ); + }); + + it( "should define a migrationsDirectory", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var config = deserializeJSON( replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ) ); + expect( config.default ).toHaveKey( "migrationsDirectory" ); + expect( config.default.migrationsDirectory ).notToBeEmpty(); + }); + + it( "should define a seedsDirectory", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var config = deserializeJSON( replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ) ); + expect( config.default ).toHaveKey( "seedsDirectory" ); + expect( config.default.seedsDirectory ).notToBeEmpty(); + }); + + it( "should define connectionInfo with database driver placeholder", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var config = deserializeJSON( replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ) ); + expect( config.default ).toHaveKey( "properties" ); + expect( config.default.properties ).toHaveKey( "connectionInfo" ); + expect( config.default.properties.connectionInfo ).toHaveKey( "type" ); + }); + + it( "should use environment variables for connection info", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var templateContent = fileRead( templatePath ); + expect( templateContent ).toInclude( "${DB_DRIVER}" ); + expect( templateContent ).toInclude( "${DB_DATABASE}" ); + expect( templateContent ).toInclude( "${DB_HOST}" ); + }); + + }); + + describe( "Migration templates", () => { + + it( "should have a CFML migration template", () => { + expect( fileExists( variables.projectRoot & "templates/Migration.txt" ) ).toBeTrue(); + }); + + it( "should have a BoxLang migration template", () => { + expect( fileExists( variables.projectRoot & "templates/MigrationBX.txt" ) ).toBeTrue(); + }); + + it( "CFML template should use component keyword", () => { + var content = fileRead( variables.projectRoot & "templates/Migration.txt" ); + expect( content ).toInclude( "component" ); + }); + + it( "BoxLang template should use class keyword", () => { + var content = fileRead( variables.projectRoot & "templates/MigrationBX.txt" ); + expect( content ).toInclude( "class" ); + }); + + it( "both templates should define up() and down() functions", () => { + var cfmlContent = fileRead( variables.projectRoot & "templates/Migration.txt" ); + var bxContent = fileRead( variables.projectRoot & "templates/MigrationBX.txt" ); + expect( cfmlContent ).toInclude( "function up(" ); + expect( cfmlContent ).toInclude( "function down(" ); + expect( bxContent ).toInclude( "function up(" ); + expect( bxContent ).toInclude( "function down(" ); + }); + + it( "both templates should accept schema and qb parameters", () => { + var cfmlContent = fileRead( variables.projectRoot & "templates/Migration.txt" ); + var bxContent = fileRead( variables.projectRoot & "templates/MigrationBX.txt" ); + expect( cfmlContent ).toInclude( "schema" ); + expect( cfmlContent ).toInclude( "qb" ); + expect( bxContent ).toInclude( "schema" ); + expect( bxContent ).toInclude( "qb" ); + }); + + }); + + describe( "Seed templates", () => { + + it( "should have a CFML seed template", () => { + expect( fileExists( variables.projectRoot & "templates/seed.txt" ) ).toBeTrue(); + }); + + it( "should have a BoxLang seed template", () => { + expect( fileExists( variables.projectRoot & "templates/seedBX.txt" ) ).toBeTrue(); + }); + + it( "CFML seed template should use component keyword", () => { + var content = fileRead( variables.projectRoot & "templates/seed.txt" ); + expect( content ).toInclude( "component" ); + }); + + it( "BoxLang seed template should use class keyword", () => { + var content = fileRead( variables.projectRoot & "templates/seedBX.txt" ); + expect( content ).toInclude( "class" ); + }); + + it( "both seed templates should define a run() function", () => { + var cfmlContent = fileRead( variables.projectRoot & "templates/seed.txt" ); + var bxContent = fileRead( variables.projectRoot & "templates/seedBX.txt" ); + expect( cfmlContent ).toInclude( "function run(" ); + expect( bxContent ).toInclude( "function run(" ); + }); + + }); + + }); + + describe( "Config Fixtures", () => { + + beforeEach( () => { + variables.fixturesDir = variables.projectRoot & "tests/resources/fixtures/"; + if ( !directoryExists( variables.fixturesDir ) ) { + directoryCreate( variables.fixturesDir ); + } + }); + + describe( "Valid .cbmigrations.json fixture", () => { + + it( "should exist", () => { + var fixturePath = variables.fixturesDir & "valid_cbmigrations.json"; + expect( fileExists( fixturePath ) ).toBeTrue(); + }); + + it( "should contain valid JSON", () => { + var fixturePath = variables.fixturesDir & "valid_cbmigrations.json"; + var config = deserializeJSON( fileRead( fixturePath ) ); + expect( isStruct( config ) ).toBeTrue(); + }); + + it( "should define a default manager", () => { + var fixturePath = variables.fixturesDir & "valid_cbmigrations.json"; + var config = deserializeJSON( fileRead( fixturePath ) ); + expect( config ).toHaveKey( "default" ); + expect( config.default ).toHaveKey( "manager" ); + }); + + }); + + describe( "Multiple managers fixture", () => { + + it( "should exist", () => { + var fixturePath = variables.fixturesDir & "multi_manager_cbmigrations.json"; + expect( fileExists( fixturePath ) ).toBeTrue(); + }); + + it( "should define multiple managers", () => { + var fixturePath = variables.fixturesDir & "multi_manager_cbmigrations.json"; + var config = deserializeJSON( fileRead( fixturePath ) ); + expect( structCount( config ) ).toBeGTE( 2 ); + }); + + it( "should define default and secondary managers", () => { + var fixturePath = variables.fixturesDir & "multi_manager_cbmigrations.json"; + var config = deserializeJSON( fileRead( fixturePath ) ); + expect( config ).toHaveKey( "default" ); + expect( config ).toHaveKey( "secondary" ); + }); + + }); + + }); + + describe( "Configuration File Patterns", () => { + + it( "should recognize .cbmigrations.json as the preferred config format", () => { + // Document the resolution order: .cbmigrations.json > .cfmigrations.json + var preferredConfig = ".cbmigrations.json"; + var legacyConfig = ".cfmigrations.json"; + expect( preferredConfig ).notToBe( legacyConfig ); + // The actual resolution logic is in findMigrationsConfigPath() + // which can only be tested with mocked dependencies + }); + + it( "should support legacy .cfmigrations.json format", () => { + // Document: legacy format is still supported but deprecated + var legacyFormat = ".cfmigrations.json"; + expect( legacyFormat ).toInclude( "cfmigrations" ); + }); + + it( "should support box.json as fallback configuration", () => { + // Document: box.json with cfmigrations key is deprecated but supported + var boxJsonConfig = { + "name" : "test-project", + "cfmigrations" : { + "managers" : { + "default" : { + "manager" : "cfmigrations.models.QBMigrationManager", + "migrationsDirectory" : "resources/database/migrations/" + } + } + } + }; + expect( boxJsonConfig ).toHaveKey( "cfmigrations" ); + expect( boxJsonConfig.cfmigrations ).toHaveKey( "managers" ); + }); + + }); + + } + +} diff --git a/tests/specs/MigrateCommandsTest.cfc b/tests/specs/MigrateCommandsTest.cfc new file mode 100644 index 0000000..7b9bc51 --- /dev/null +++ b/tests/specs/MigrateCommandsTest.cfc @@ -0,0 +1,231 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Tests for the migrate install/up/down/reset/fresh command structure and behavior patterns. + */ +component extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.projectRoot = request.commandboxMigrationsProjectRoot; + } + + function run() { + + describe( "migrate install command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/install.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/install.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should call setup() before installation", () => { + var commandPath = variables.projectRoot & "commands/migrate/install.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "setup(" ); + } ); + + it( "should check if migration service is ready before installing", () => { + var commandPath = variables.projectRoot & "commands/migrate/install.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "isReady" ); + } ); + + it( "should call migrationService.install()", () => { + var commandPath = variables.projectRoot & "commands/migrate/install.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "migrationService.install()" ); + } ); + + } ); + + describe( "migrate up command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should support once parameter for single migration", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean once" ); + } ); + + it( "should support pretend parameter for dry-run mode", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean pretend" ); + } ); + + it( "should support file parameter for specific migration", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string file" ); + } ); + + it( "should support seed parameter to run seeders", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean seed" ); + } ); + + it( "should use hooks for pre/post migration logging", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "preProcessHook" ); + expect( content ).toInclude( "postProcessHook" ); + } ); + + it( "should clear page pool before execution", () => { + var commandPath = variables.projectRoot & "commands/migrate/up.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "pagePoolClear" ); + } ); + + } ); + + describe( "migrate down command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/down.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/down.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should support file parameter for specific rollback", () => { + var commandPath = variables.projectRoot & "commands/migrate/down.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string file" ); + } ); + + it( "should support pretend parameter for dry-run mode", () => { + var commandPath = variables.projectRoot & "commands/migrate/down.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean pretend" ); + } ); + + it( "should roll back the last batch when no file specified", () => { + var commandPath = variables.projectRoot & "commands/migrate/down.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "runAllMigrations" ); + expect( content ).toInclude( 'direction = "down"' ); + } ); + + } ); + + describe( "migrate reset command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/reset.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/reset.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should delegate reset behavior to the migration service", () => { + var commandPath = variables.projectRoot & "commands/migrate/reset.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "migrationService.reset()" ); + } ); + + } ); + + describe( "migrate fresh command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/fresh.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/fresh.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should chain reset, install, and up commands", () => { + var commandPath = variables.projectRoot & "commands/migrate/fresh.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "command(" ) + // Should call at least migrate reset, migrate install, and migrate up + } ); + + it( "should use .run() to execute chained commands", () => { + var commandPath = variables.projectRoot & "commands/migrate/fresh.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( ".run()" ); + } ); + + } ); + + describe( "Common error handling patterns", () => { + + it( "commands with direct migration service calls should have try/catch blocks", () => { + var commands = [ "install.cfc", "up.cfc", "down.cfc", "reset.cfc" ]; + for ( var cmd in commands ) { + var commandPath = variables.projectRoot & "commands/migrate/#cmd#"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "try {" ); + expect( content ).toInclude( "catch" ); + } + } ); + + it( "commands with direct migration service calls should format SQL on errors", () => { + var commands = [ "install.cfc", "up.cfc", "down.cfc", "reset.cfc" ]; + for ( var cmd in commands ) { + var commandPath = variables.projectRoot & "commands/migrate/#cmd#"; + var content = fileRead( commandPath ); + // Should reference sqlHighlighter or sqlFormatter in catch blocks + expect( content ).toInclude( "sqlHighlighter" ); + } + } ); + + it( "commands with direct migration service calls should use error() to propagate exit codes", () => { + var commands = [ "install.cfc", "up.cfc", "down.cfc", "reset.cfc" ]; + for ( var cmd in commands ) { + var commandPath = variables.projectRoot & "commands/migrate/#cmd#"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "error(" ); + } + } ); + + it( "all commands should support verbose parameter for diagnostics", () => { + var commands = [ "install.cfc", "up.cfc", "down.cfc", "reset.cfc", "fresh.cfc" ]; + for ( var cmd in commands ) { + var commandPath = variables.projectRoot & "commands/migrate/#cmd#"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean verbose" ); + } + } ); + + } ); + } + +} diff --git a/tests/specs/MigrateCreateTest.cfc b/tests/specs/MigrateCreateTest.cfc new file mode 100644 index 0000000..b8cc274 --- /dev/null +++ b/tests/specs/MigrateCreateTest.cfc @@ -0,0 +1,150 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Tests for the migrate create command structure, template selection, + * and timestamp generation patterns. + */ +component extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.projectRoot = request.commandboxMigrationsProjectRoot; + } + + function run() { + + describe( "migrate create command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should define a run() method", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "function run(" ); + } ); + + it( "should accept a name parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string name" ); + } ); + + it( "should accept a manager parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string manager" ); + } ); + + it( "should accept a boxlang boolean parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean boxlang" ); + } ); + + it( "should call setup() with setupDatasource=false", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "setup(" ).toInclude( "setupDatasource" ); + } ); + + it( "should choose the CFML migration template when boxlang is false", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "Migration##arguments.boxlang" ); + expect( content ).toInclude( '? "BX" : ""' ); + } ); + + it( "should choose the BoxLang migration template when boxlang is true", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "Migration##arguments.boxlang" ); + expect( content ).toInclude( '? "BX" : ""' ); + } ); + + it( "should use the yyyy_MM_dd_HHnnss timestamp format", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "yyyy_MM_dd_HHnnss" ); + } ); + + it( "should create the migrationsDirectory if it does not exist", () => { + var commandPath = variables.projectRoot & "commands/migrate/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "directoryCreate" ); + } ); + + } ); + + describe( "migrate create migration templates", () => { + + it( "should have a CFML migration template", () => { + var templatePath = variables.projectRoot & "templates/Migration.txt"; + expect( fileExists( templatePath ) ).toBeTrue(); + } ); + + it( "should have a BoxLang migration template", () => { + var templatePath = variables.projectRoot & "templates/MigrationBX.txt"; + expect( fileExists( templatePath ) ).toBeTrue(); + } ); + + it( "CFML template should use component keyword", () => { + var content = fileRead( variables.projectRoot & "templates/Migration.txt" ); + expect( content ).toInclude( "component" ); + } ); + + it( "BoxLang template should use class keyword", () => { + var content = fileRead( variables.projectRoot & "templates/MigrationBX.txt" ); + expect( content ).toInclude( "class" ); + } ); + + it( "CFML template should define up() and down() methods", () => { + var content = fileRead( variables.projectRoot & "templates/Migration.txt" ); + expect( content ).toInclude( "function up(" ); + expect( content ).toInclude( "function down(" ); + } ); + + it( "BoxLang template should define up() and down() methods", () => { + var content = fileRead( variables.projectRoot & "templates/MigrationBX.txt" ); + expect( content ).toInclude( "function up(" ); + expect( content ).toInclude( "function down(" ); + } ); + + it( "Both templates should accept schema and qb arguments", () => { + var cfmlContent = fileRead( variables.projectRoot & "templates/Migration.txt" ); + var bxContent = fileRead( variables.projectRoot & "templates/MigrationBX.txt" ); + expect( cfmlContent ).toInclude( "schema" ); + expect( cfmlContent ).toInclude( "qb" ); + expect( bxContent ).toInclude( "schema" ); + expect( bxContent ).toInclude( "qb" ); + } ); + + } ); + + describe( "migrate create timestamp format", () => { + + it( "should generate a valid timestamp string", () => { + // Simulate what the command does + var timestamp = dateFormat( now(), "yyyy_MM_dd" ) & "_" & timeFormat( now(), "HHnnss" ); + expect( reFind( "^\d{4}_\d{2}_\d{2}_\d{6}$", timestamp ) ).toBeTrue(); + } ); + + it( "should produce sortable filenames", () => { + var ts1 = dateFormat( "2024-01-01", "yyyy_MM_dd" ) & "_" & timeFormat( "08:00:00", "HHnnss" ); + var ts2 = dateFormat( "2024-01-02", "yyyy_MM_dd" ) & "_" & timeFormat( "08:00:00", "HHnnss" ); + expect( ts1 < ts2 ).toBeTrue(); + } ); + + } ); + } + +} diff --git a/tests/specs/MigrateInitTest.cfc b/tests/specs/MigrateInitTest.cfc new file mode 100644 index 0000000..229b2fb --- /dev/null +++ b/tests/specs/MigrateInitTest.cfc @@ -0,0 +1,125 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Tests for the migrate init command structure, template usage, + * and configuration generation patterns. + * + * Since CommandBox commands require shell context to execute, + * these tests validate the command's file structure, template + * resolution, and expected configuration output. + */ +component extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.projectRoot = request.commandboxMigrationsProjectRoot; + } + + function run() { + + describe( "migrate init command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should define a run() method", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "function run(" ); + } ); + + it( "should accept an open parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean open" ); + } ); + + it( "should reference the config.txt template", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "config.txt" ); + } ); + + it( "should check if .cbmigrations.json already exists before creating", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "fileExists" ); + expect( content ).toInclude( ".cbmigrations.json" ); + } ); + + it( "should write the config file with mode 777", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "mode=" ); + expect( content ).toInclude( "777" ); + } ); + + } ); + + describe( "init command config template output", () => { + + it( "should produce valid JSON when config.txt is written", () => { + var templatePath = variables.projectRoot & "templates/config.txt"; + var content = replace( fileRead( templatePath ), "$" & "{DB_PORT}", "5432" ); + // trim() is applied in the command before writing + var config = deserializeJSON( trim( content ) ); + expect( isStruct( config ) ).toBeTrue(); + } ); + + it( "should produce a config with the correct default manager", () => { + var content = replace( fileRead( variables.projectRoot & "templates/config.txt" ), "$" & "{DB_PORT}", "5432" ); + var config = deserializeJSON( trim( content ) ); + expect( config.default.manager ).toBe( "cfmigrations.models.QBMigrationManager" ); + } ); + + it( "should produce a config with environment variable placeholders", () => { + var content = fileRead( variables.projectRoot & "templates/config.txt" ); + expect( content ).toInclude( "${DB_DRIVER}" ); + expect( content ).toInclude( "${DB_DATABASE}" ); + expect( content ).toInclude( "${DB_HOST}" ); + } ); + + it( "should produce a config file under 500 characters after trimming", () => { + // The config template should be compact enough for a config file + var content = trim( fileRead( variables.projectRoot & "templates/config.txt" ) ); + expect( len( content ) ).toBeGT( 100 ); + expect( len( content ) ).toBeLT( 2000 ); + } ); + + } ); + + describe( "init command idempotency logic", () => { + + it( "should use fileExists() to detect existing configs", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "fileExists( configPath )" ); + } ); + + it( "should return early when config already exists", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + // Should have a return statement within the fileExists check + expect( content ).toInclude( "return" ); + } ); + + it( "should print a yellow warning when config already exists", () => { + var commandPath = variables.projectRoot & "commands/migrate/init.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "print.yellowLine" ); + expect( content ).toInclude( "already exists" ); + } ); + + } ); + } + +} diff --git a/tests/specs/MigrateSeedCreateTest.cfc b/tests/specs/MigrateSeedCreateTest.cfc new file mode 100644 index 0000000..31c8bab --- /dev/null +++ b/tests/specs/MigrateSeedCreateTest.cfc @@ -0,0 +1,131 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Tests for the migrate seed create command structure and template selection. + */ +component extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.projectRoot = request.commandboxMigrationsProjectRoot; + } + + function run() { + + describe( "migrate seed create command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should define a run() method", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "function run(" ); + } ); + + it( "should accept a name parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string name" ); + } ); + + it( "should accept a manager parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string manager" ); + } ); + + it( "should accept a boxlang boolean parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean boxlang" ); + } ); + + it( "should call setup() with setupDatasource=false", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "setup(" ).toInclude( "setupDatasource" ); + } ); + + it( "should choose the CFML seed template when boxlang is false", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "seed##arguments.boxlang" ); + expect( content ).toInclude( '? "BX" : ""' ); + } ); + + it( "should choose the BoxLang seed template when boxlang is true", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "seed##arguments.boxlang" ); + expect( content ).toInclude( '? "BX" : ""' ); + } ); + + it( "should use the provided seed name without a timestamp", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "##arguments.name##.##extension##" ); + } ); + + it( "should create the seedsDirectory if it does not exist", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/create.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "directoryCreate" ); + } ); + + } ); + + describe( "migrate seed create templates", () => { + + it( "should have a CFML seed template", () => { + var templatePath = variables.projectRoot & "templates/seed.txt"; + expect( fileExists( templatePath ) ).toBeTrue(); + } ); + + it( "should have a BoxLang seed template", () => { + var templatePath = variables.projectRoot & "templates/seedBX.txt"; + expect( fileExists( templatePath ) ).toBeTrue(); + } ); + + it( "CFML template should use component keyword", () => { + var content = fileRead( variables.projectRoot & "templates/seed.txt" ); + expect( content ).toInclude( "component" ); + } ); + + it( "BoxLang template should use class keyword", () => { + var content = fileRead( variables.projectRoot & "templates/seedBX.txt" ); + expect( content ).toInclude( "class" ); + } ); + + it( "CFML template should define a run() method", () => { + var content = fileRead( variables.projectRoot & "templates/seed.txt" ); + expect( content ).toInclude( "function run(" ); + } ); + + it( "BoxLang template should define a run() method", () => { + var content = fileRead( variables.projectRoot & "templates/seedBX.txt" ); + expect( content ).toInclude( "function run(" ); + } ); + + it( "Both templates should accept qb and mockdata arguments", () => { + var cfmlContent = fileRead( variables.projectRoot & "templates/seed.txt" ); + var bxContent = fileRead( variables.projectRoot & "templates/seedBX.txt" ); + expect( cfmlContent ).toInclude( "qb" ); + expect( cfmlContent ).toInclude( "mockdata" ); + expect( bxContent ).toInclude( "qb" ); + expect( bxContent ).toInclude( "mockdata" ); + } ); + + } ); + } + +} diff --git a/tests/specs/MigrateSeedRunTest.cfc b/tests/specs/MigrateSeedRunTest.cfc new file mode 100644 index 0000000..c6d1324 --- /dev/null +++ b/tests/specs/MigrateSeedRunTest.cfc @@ -0,0 +1,126 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Tests for the migrate seed run command structure and execution patterns. + */ +component extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.projectRoot = request.commandboxMigrationsProjectRoot; + } + + function run() { + + describe( "migrate seed run command structure", () => { + + it( "should be a valid CFC file", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + expect( fileExists( commandPath ) ).toBeTrue(); + } ); + + it( "should extend BaseMigrationCommand", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "extends=" ) + expect( content ).toInclude( "BaseMigrationCommand" ); + } ); + + it( "should define a run() method", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "function run(" ); + } ); + + it( "should accept a manager parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string manager" ); + } ); + + it( "should accept a name parameter for a specific seeder", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "string name" ); + } ); + + it( "should accept a verbose boolean parameter", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "boolean verbose" ); + } ); + + it( "should call setup() before execution", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "setup(" ); + } ); + + it( "should clear page pool before seed execution", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "pagePoolClear" ); + } ); + + it( "should use preProcessHook for logging", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "preProcessHook" ); + } ); + + it( "should use postProcessHook for logging", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "postProcessHook" ); + } ); + + } ); + + describe( "migrate seed run error handling", () => { + + it( "should have try/catch blocks", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "try {" ); + expect( content ).toInclude( "catch" ); + } ); + + it( "should format SQL on errors", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "sqlHighlighter" ); + } ); + + it( "should use error() to propagate exit codes", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "error(" ); + } ); + + it( "should check SQL existence in error structure", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "structKeyExists" ); + } ); + + } ); + + describe( "migrate seed run seeder selection", () => { + + it( "should pass the selected seed name to the migration service", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "seedName" ); + expect( content ).toInclude( "arguments.name" ); + } ); + + it( "should report when no seeders run", () => { + var commandPath = variables.projectRoot & "commands/migrate/seed/run.cfc"; + var content = fileRead( commandPath ); + expect( content ).toInclude( "No seeders to run" ); + } ); + + } ); + } + +}