diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000000..d291ace125 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,195 @@ +# Liquibase - Claude Code Instructions + +## Tech Stack + +- **Language**: Java 21 +- **Build**: Maven 3.8.4+ (multi-module project) +- **Testing**: JUnit Jupiter 5.13.3, Spock 2.4-M6-groovy-4.0, Mockito 5.18.0 +- **Logging**: Custom logging framework (liquibase.logging) +- **Serialization**: SnakeYAML 2.4 for YAML support +- **Code Quality**: JaCoCo for coverage, SonarCloud for analysis + +## Project Structure + +``` +liquibase/ ++-- pom.xml (parent POM) ++-- liquibase-core/ (core engine) ++-- liquibase-maven-plugin/ (Maven integration) ++-- liquibase-integration-tests/ (integration tests) ++-- liquibase-debian/ (Linux deb packaging) ++-- liquibase-rpm/ (Linux rpm packaging) +``` + +## Build Commands + +```bash +# Full build +mvn clean install + +# Skip tests +mvn clean install -DskipTests + +# Run tests only +mvn test + +# Build specific module +mvn clean install -pl liquibase-core + +# Build with dependencies +mvn clean install -pl liquibase-maven-plugin -am + +# Run integration tests +mvn verify -pl liquibase-integration-tests + +# Run with Oracle profile +mvn verify -Poracle +``` + +## Code Patterns + +### Adding a New Change Type + +1. Create class in `liquibase-core/src/main/java/liquibase/change/core/` +2. Extend `AbstractChange` +3. Annotate with `@DatabaseChange` +4. Implement `generateStatements()` and `createInverses()` +5. Register in `META-INF/services/liquibase.change.Change` + +### Adding Database Support + +1. Create class in `liquibase-core/src/main/java/liquibase/database/core/` +2. Extend `AbstractJdbcDatabase` +3. Override database-specific methods +4. Register in `META-INF/services/liquibase.database.Database` + +### Adding SQL Generator + +1. Create class in `liquibase-core/src/main/java/liquibase/sqlgenerator/core/` +2. Extend `AbstractSqlGenerator` +3. Set priority for database-specific generators +4. Register in `META-INF/services/liquibase.sqlgenerator.SqlGenerator` + +## Anti-Patterns + +**AVOID:** +- Direct SQL string construction without escaping (use `database.escapeObjectName()`) +- Hardcoding database-specific syntax in Change classes (use SqlGenerator) +- Breaking backwards compatibility in changelog parsing +- Modifying DATABASECHANGELOG table structure without migration path +- Ignoring transaction boundaries in multi-statement changes + +**DO NOT:** +- Add new dependencies without updating parent POM dependencyManagement +- Skip precondition validation in change execution +- Bypass LockService for database modifications +- Create new changelog formats without parser registration + +## Quality Gates + +### Before Commit + +1. Run `mvn verify` to ensure all tests pass +2. Check for new compiler warnings +3. Ensure changelog formats remain backwards compatible +4. Verify precondition checks for new changes + +### Code Review Focus + +- Database abstraction maintained +- SQL injection prevention +- Rollback support implemented +- Thread safety considerations +- Backwards compatibility + +## Testing Strategy + +### Unit Tests + +- Location: `src/test/java/` in each module +- Framework: JUnit Jupiter +- Mock external dependencies with Mockito +- Test SQL generation with MockDatabase + +### Integration Tests + +- Location: `liquibase-integration-tests/` +- Framework: Spock + JUnit +- Tests run against H2, HSQLDB, Derby (embedded) +- Use `@Disabled` for database-specific tests requiring external setup + +### Running Specific Tests + +```bash +# Single test class +mvn test -Dtest=CreateTableChangeTest + +# Single test method +mvn test -Dtest=CreateTableChangeTest#testGenerateStatements + +# Pattern matching +mvn test -Dtest=*OracleTest +``` + +## File Organization + +### Source Files + +``` +src/main/java/liquibase/ ++-- change/ # Change type definitions ++-- changelog/ # Changelog parsing and processing ++-- database/ # Database abstractions ++-- diff/ # Database comparison ++-- executor/ # SQL execution ++-- parser/ # Changelog file parsers ++-- precondition/ # Precondition checks ++-- snapshot/ # Database state capture ++-- sql/ # SQL statements ++-- sqlgenerator/ # SQL generation +``` + +### Resource Files + +``` +src/main/resources/ ++-- META-INF/ + +-- services/ # ServiceLoader registrations + +-- MANIFEST.MF # OSGi bundle manifest ++-- liquibase.build.properties +``` + +## Naming Conventions + +### Classes + +- Changes: `{Action}{Object}Change` (e.g., `CreateTableChange`, `AddColumnChange`) +- Databases: `{DatabaseName}Database` (e.g., `OracleDatabase`, `PostgresDatabase`) +- Generators: `{Statement}Generator{Database}` (e.g., `CreateTableGeneratorOracle`) +- Statements: `{Action}{Object}Statement` (e.g., `CreateTableStatement`) + +### Packages + +- Database-specific: `liquibase.*.core` (e.g., `liquibase.database.core`) +- Integration: `liquibase.integration.*` (e.g., `liquibase.integration.ant`) + +### Test Classes + +- Unit tests: `{ClassName}Test` +- Spock specs: `{ClassName}Spec` +- Integration tests: `{Feature}IntegrationTest` + +## IDE Setup + +### IntelliJ IDEA + +- Import as Maven project +- Enable Groovy plugin for Spock tests +- Set Java SDK to 21 +- Configure Groovy SDK from Maven dependency + +### Useful Run Configurations + +- `mvn clean install -DskipTests` - Fast build +- `mvn test -pl liquibase-core` - Core tests only +- `mvn verify` - Full verification including integration tests diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000000..25eae47f1e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,276 @@ +# Liquibase Architecture + +## High-Level Architecture + +``` ++-- User Interface Layer + +-- CLI (Main class in liquibase.integration.commandline) + +-- Maven Plugin (liquibase-maven-plugin) + +-- Ant Tasks (liquibase.integration.ant) + +-- Servlet Listener (liquibase.integration.servlet) + +-- Spring Integration (liquibase.integration.spring) + ++-- Core API Layer (liquibase.Liquibase) + +-- Changelog Parsing + +-- Change Execution + +-- Rollback Management + +-- Diff/Snapshot Operations + ++-- Database Abstraction Layer + +-- Database Interface + +-- SQL Generator Layer + +-- Executor Layer + ++-- Database Drivers (JDBC) + +-- PostgreSQL, MySQL, Oracle, SQL Server, etc. +``` + +## Component Relationships + +### Entry Points + +All interactions flow through the main `Liquibase` class which orchestrates: +1. Changelog parsing +2. Database connection management +3. Lock acquisition +4. Change execution +5. History tracking + +### Core Flow: Update Operation + +``` +1. Liquibase.update() called + | + v +2. LockService.acquireLock() + | + v +3. ChangeLogParser.parse(changelogFile) + | + v +4. For each ChangeSet: + +-- Check preconditions + +-- Check if already ran (DATABASECHANGELOG) + +-- Generate SQL via SqlGenerator + +-- Execute via Executor + +-- Record in DATABASECHANGELOG + | + v +5. LockService.releaseLock() +``` + +## Data Flow + +### Changelog Processing + +``` +Changelog File (XML/YAML/JSON/SQL) + | + v +ChangeLogParser (selects appropriate parser) + | + v +DatabaseChangeLog (parsed object model) + | + v +ChangeSet objects (executable units) + | + v +Change objects (AddColumn, CreateTable, etc.) + | + v +Statement objects (SqlStatement implementations) + | + v +SqlGenerator (database-specific SQL) + | + v +Executor (JDBC execution) +``` + +### SQL Generation Pipeline + +``` +Change.generateStatements(Database) + | + v +SqlStatement (e.g., CreateTableStatement) + | + v +SqlGeneratorFactory.generateSql(statement, database) + | + v +SqlGenerator.generateSql(statement, database, sqlGeneratorChain) + | + v +Sql[] (executable SQL strings) +``` + +## Deployment Architecture + +### Standalone Deployment + +``` ++-- Application Server / JVM + +-- liquibase-core.jar + +-- Database JDBC Driver + +-- changelog files (classpath or filesystem) +``` + +### Maven Build Integration + +``` ++-- Maven Build Lifecycle + +-- liquibase-maven-plugin + +-- liquibase-core (transitive) + +-- Project classpath (JDBC drivers, etc.) + +-- Executes during build phase (e.g., process-resources) +``` + +### Web Application Deployment + +``` ++-- Web Application (WAR) + +-- WEB-INF/lib/ + +-- liquibase-core.jar + +-- JDBC driver + +-- WEB-INF/web.xml + +-- LiquibaseServletListener + +-- changelog files in classpath +``` + +## Technology Decisions + +### Language and Platform +- **Java 21**: Target runtime (configured in maven-compiler-plugin) +- **Groovy 4.0**: Used for Spock tests +- **Maven 3.8.4+**: Build system requirement + +### Serialization +- **SnakeYAML 2.4**: YAML changelog support +- **Custom XML parsing**: Direct SAX/DOM parsing for XML changelogs +- **JSON support**: Built-in JSON changelog support + +### Extension Mechanism +- **ServiceLoader pattern**: Plugin discovery via META-INF/services +- **Priority-based selection**: Multiple implementations sorted by priority + +### OSGi Support +- **Bundle manifest**: Generated via maven-bundle-plugin +- **Import-Package**: Configured for optional dependencies + +## Scalability Considerations + +### Database Lock Mechanism +- Uses DATABASECHANGELOGLOCK table +- Prevents concurrent migrations +- Single-node execution model +- Lock timeout configurable + +### Changelog Organization +- **Include/IncludeAll**: Split changelogs across files +- **Context/Label filtering**: Selective changeset execution +- **Preconditions**: Runtime conditional execution + +### Performance +- Changelog parsing happens once per execution +- Statement caching in SqlGeneratorFactory +- Connection pooling delegated to calling application + +--- + +## Module-Specific Architectures + +### liquibase-core Architecture + +**Package Structure:** +``` +liquibase/ ++-- Liquibase.java (Main facade class) ++-- CatalogAndSchema.java ++-- Contexts.java, Labels.java ++-- change/ + +-- core/ (Built-in changes: AddColumn, CreateTable, etc.) + +-- custom/ (Custom change interfaces) ++-- changelog/ + +-- DatabaseChangeLog.java + +-- ChangeSet.java + +-- filter/ (Changeset filtering) + +-- visitor/ (Visitor pattern for processing) ++-- command/ (Command pattern for operations) ++-- configuration/ (Configuration management) ++-- database/ + +-- Database.java (Main interface) + +-- AbstractJdbcDatabase.java + +-- core/ (Database implementations) ++-- diff/ + +-- DiffGenerator.java + +-- compare/ (Object comparison) + +-- output/ (Diff output generation) ++-- exception/ (Custom exceptions) ++-- executor/ + +-- Executor.java + +-- jvm/JdbcExecutor.java ++-- lockservice/ + +-- LockService.java + +-- LockServiceImpl.java ++-- parser/ + +-- core/xml/, core/yaml/, core/json/ ++-- precondition/ + +-- Precondition.java + +-- core/ (Built-in preconditions) ++-- serializer/ (Output serialization) ++-- servicelocator/ (Plugin discovery) ++-- snapshot/ (Database state capture) ++-- sql/ (SQL statement representations) ++-- sqlgenerator/ (Database-specific SQL generation) ++-- statement/ (Statement types) ++-- structure/ (Database object model) ++-- util/ (Utilities) +``` + +**Key Classes:** +- `Liquibase`: Main entry point, orchestrates all operations +- `Database`: Abstraction for database-specific behavior +- `ChangeSet`: Unit of database change +- `SqlGenerator`: Converts statements to database-specific SQL +- `Executor`: Executes SQL against the database + +### liquibase-maven-plugin Architecture + +**Goal Structure:** +``` +liquibase-maven-plugin/ ++-- src/main/java/org/liquibase/maven/plugins/ + +-- AbstractLiquibaseMojo.java (Base for all goals) + +-- LiquibaseUpdate.java (update goal) + +-- LiquibaseRollback.java (rollback goal) + +-- LiquibaseDiff.java (diff goal) + +-- etc. +``` + +**Mojo Pattern:** +- Each goal extends AbstractLiquibaseMojo +- Configuration via Maven @Parameter annotations +- Classpath handling for JDBC drivers +- Property interpolation from Maven project + +### liquibase-integration-tests Architecture + +**Test Organization:** +``` +liquibase-integration-tests/ ++-- src/test/java/ + +-- Integration test classes ++-- src/test/groovy/ + +-- Spock specifications ++-- src/test/resources/ + +-- Test changelogs + +-- Database configurations ++-- src/test/filtered-resources/ + +-- Property-filtered resources +``` + +**Test Profiles:** +- Default: H2, HSQLDB, Derby (embedded databases) +- `oracle`: Oracle database testing with JDBC driver diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 0000000000..9bee2fb747 --- /dev/null +++ b/docs/patterns.md @@ -0,0 +1,429 @@ +# Liquibase Code Patterns + +## Component Patterns + +### Service Locator Pattern + +Liquibase uses a service locator for plugin discovery and extensibility: + +```java +// Finding all implementations of an interface +ServiceLocator locator = ServiceLocator.getInstance(); +List databases = locator.findInstances(Database.class); + +// Services register via META-INF/services files +// e.g., META-INF/services/liquibase.database.Database +``` + +### Factory Pattern + +SQL generation uses factory pattern for database-specific implementations: + +```java +// SqlGeneratorFactory selects appropriate generator +SqlGeneratorFactory factory = SqlGeneratorFactory.getInstance(); +Sql[] sql = factory.generateSql(statement, database); + +// Generators are registered and selected by priority +@LiquibaseService +public class CreateTableGeneratorOracle extends AbstractSqlGenerator { + @Override + public int getPriority() { + return PRIORITY_DATABASE; // Higher priority for database-specific + } +} +``` + +### Visitor Pattern + +Changelog processing uses visitor pattern: + +```java +// ChangeSetVisitor interface +public interface ChangeSetVisitor { + Direction getDirection(); + void visit(ChangeSet changeSet, DatabaseChangeLog changeLog, Database database); +} + +// Used by Liquibase for various operations +changeLog.accept(new ChangeSetVisitor() { + @Override + public void visit(ChangeSet changeSet, DatabaseChangeLog changeLog, Database database) { + // Process each changeset + } +}); +``` + +### Template Method Pattern + +Abstract base classes define algorithms with extension points: + +```java +// AbstractChange defines the change processing template +public abstract class AbstractChange implements Change { + // Template method + public final SqlStatement[] generateStatements(Database database) { + // Pre-processing + validate(database); + // Delegate to subclass + return generateStatementsVolatile(database); + } + + // Override point + protected abstract SqlStatement[] generateStatementsVolatile(Database database); +} +``` + +## State Management Patterns + +### Lock Service Pattern + +Distributed lock management for concurrent access: + +```java +// Acquire lock before migrations +LockService lockService = LockServiceFactory.getInstance().getLockService(database); +lockService.waitForLock(); + +try { + // Perform migrations +} finally { + lockService.releaseLock(); +} +``` + +### Change History Tracking + +All changes tracked in DATABASECHANGELOG: + +```java +// Check if changeset was already executed +ChangeLogHistoryService historyService = ChangeLogHistoryServiceFactory.getInstance() + .getChangeLogService(database); + +List ranChangeSets = historyService.getRanChangeSets(); + +// Record execution after successful run +historyService.setExecType(changeSet, execType); +``` + +## API Integration Patterns + +### Database Abstraction + +All database operations go through the Database interface: + +```java +// Database interface abstracts dialect differences +public interface Database { + String getShortName(); + String getDefaultDriver(String url); + boolean supportsSequences(); + String escapeObjectName(String objectName, Class objectType); + // ... +} + +// Implementations handle database-specific behavior +public class PostgresDatabase extends AbstractJdbcDatabase { + @Override + public String getShortName() { + return "postgresql"; + } +} +``` + +### Statement/Generator Separation + +SQL statement structure separated from generation: + +```java +// Statement defines what to do (data) +public class CreateTableStatement extends AbstractSqlStatement { + private String tableName; + private List columns; + // ... +} + +// Generator defines how to do it (behavior) +public class CreateTableGenerator extends AbstractSqlGenerator { + @Override + public Sql[] generateSql(CreateTableStatement statement, Database database, ...) { + StringBuilder sql = new StringBuilder("CREATE TABLE "); + sql.append(database.escapeTableName(...)); + // Database-specific SQL generation + return new Sql[] { new UnparsedSql(sql.toString()) }; + } +} +``` + +## Error Handling Patterns + +### Custom Exception Hierarchy + +```java +// Base exception +public class LiquibaseException extends Exception { + // Common exception handling +} + +// Specific exceptions +public class DatabaseException extends LiquibaseException { + // Database-related errors +} + +public class ChangeLogParseException extends LiquibaseException { + // Parsing errors +} + +public class PreconditionFailedException extends LiquibaseException { + // Precondition check failures +} +``` + +### Precondition Validation + +Conditional execution with clear failure modes: + +```java +// Precondition check before changeset execution +public interface Precondition { + void check(Database database, DatabaseChangeLog changeLog, ChangeSet changeSet) + throws PreconditionFailedException, PreconditionErrorException; +} + +// Usage in changeset + + + + + + +``` + +## Testing Patterns + +### JUnit Jupiter Tests + +```java +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.*; + +class CreateTableChangeTest { + private CreateTableChange change; + + @BeforeEach + void setUp() { + change = new CreateTableChange(); + } + + @Test + void generateStatements_createsValidSQL() { + change.setTableName("test_table"); + // Add columns... + + SqlStatement[] statements = change.generateStatements(new MockDatabase()); + + assertNotNull(statements); + assertEquals(1, statements.length); + } +} +``` + +### Spock Specifications (Groovy) + +```groovy +import spock.lang.Specification + +class DatabaseFactorySpec extends Specification { + + def "findCorrectDatabaseImplementation returns correct database for URL"() { + given: + def factory = DatabaseFactory.getInstance() + + when: + def database = factory.findCorrectDatabaseImplementation(url) + + then: + database.class == expectedClass + + where: + url | expectedClass + "jdbc:postgresql://localhost/test" | PostgresDatabase + "jdbc:mysql://localhost/test" | MySQLDatabase + "jdbc:h2:mem:test" | H2Database + } +} +``` + +### Mockito Mocking + +```java +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExecutorTest { + + @Mock + private Database database; + + @Mock + private DatabaseConnection connection; + + @Test + void execute_callsConnectionExecute() { + when(database.getConnection()).thenReturn(connection); + + executor.execute(statement, database); + + verify(connection).execute(any(String.class)); + } +} +``` + +--- + +## Module-Specific Patterns + +### liquibase-core Patterns + +**Change Implementation Pattern:** +```java +@DatabaseChange( + name = "createTable", + description = "Creates a new table", + priority = ChangeMetaData.PRIORITY_DEFAULT +) +public class CreateTableChange extends AbstractChange { + + private String tableName; + private String schemaName; + private List columns; + + // Getters/setters with @DatabaseChangeProperty annotations + @DatabaseChangeProperty(description = "Name of the table to create") + public String getTableName() { + return tableName; + } + + @Override + public SqlStatement[] generateStatements(Database database) { + CreateTableStatement statement = new CreateTableStatement( + getCatalogName(), getSchemaName(), getTableName()); + // Add columns to statement + return new SqlStatement[] { statement }; + } + + @Override + protected Change[] createInverses() { + DropTableChange inverse = new DropTableChange(); + inverse.setTableName(getTableName()); + return new Change[] { inverse }; + } +} +``` + +**Database Implementation Pattern:** +```java +public class OracleDatabase extends AbstractJdbcDatabase { + + @Override + public int getPriority() { + return PRIORITY_DEFAULT; + } + + @Override + public boolean isCorrectDatabaseImplementation(DatabaseConnection conn) { + return getDatabaseProductName(conn).startsWith("Oracle"); + } + + @Override + public String getShortName() { + return "oracle"; + } + + @Override + public boolean supportsSequences() { + return true; + } + + @Override + public String getAutoIncrementClause() { + // Oracle uses sequences, not auto-increment + return ""; + } +} +``` + +### liquibase-maven-plugin Patterns + +**Maven Goal Pattern:** +```java +@Mojo( + name = "update", + defaultPhase = LifecyclePhase.PROCESS_RESOURCES +) +public class LiquibaseUpdate extends AbstractLiquibaseMojo { + + @Parameter(property = "liquibase.changeLogFile", required = true) + protected String changeLogFile; + + @Parameter(property = "liquibase.contexts") + protected String contexts; + + @Override + protected void performLiquibaseTask(Liquibase liquibase) throws LiquibaseException { + liquibase.update(new Contexts(contexts), new LabelExpression(labels)); + } +} +``` + +### liquibase-integration-tests Patterns + +**Integration Test Pattern:** +```java +public abstract class AbstractIntegrationTest { + + protected Database database; + protected Liquibase liquibase; + + @BeforeEach + void setUp() throws Exception { + database = DatabaseFactory.getInstance() + .findCorrectDatabaseImplementation(new JdbcConnection(getConnection())); + liquibase = new Liquibase(getChangeLogFile(), + new ClassLoaderResourceAccessor(), database); + } + + @AfterEach + void tearDown() throws Exception { + if (database != null) { + database.close(); + } + } + + protected abstract Connection getConnection() throws SQLException; + protected abstract String getChangeLogFile(); +} +``` + +**Maven Verifier Pattern:** +```java +public class MavenPluginIntegrationTest { + + @Test + void updateGoal_executesSuccessfully() throws Exception { + File testDir = ResourceExtractor.simpleExtractResources( + getClass(), "/integration-test-project"); + + Verifier verifier = new Verifier(testDir.getAbsolutePath()); + verifier.executeGoal("liquibase:update"); + verifier.verifyErrorFreeLog(); + + // Verify database state + verifier.verifyTextInLog("Successfully acquired change log lock"); + } +} +``` diff --git a/docs/project-context.md b/docs/project-context.md new file mode 100644 index 0000000000..9a04b7b492 --- /dev/null +++ b/docs/project-context.md @@ -0,0 +1,177 @@ +# Liquibase Project Context + +## Business Context + +Liquibase is an open-source database schema change management solution. It enables teams to track, version, and deploy database changes across different environments in a controlled and repeatable manner. + +**Key Value Propositions:** +- Database-agnostic schema migration tool +- Tracks all database changes in version control +- Supports rollback capabilities +- Integrates with CI/CD pipelines +- Reduces deployment risks through controlled migrations + +## Domain Knowledge + +### Core Concepts + +- **ChangeSet**: The atomic unit of change in Liquibase. Each changeset has a unique ID and author combination. +- **ChangeLog**: A file (XML, YAML, JSON, or SQL) that contains an ordered list of changesets. +- **DATABASECHANGELOG**: A tracking table that records which changesets have been executed. +- **DATABASECHANGELOGLOCK**: A lock table that prevents concurrent migrations. +- **Preconditions**: Conditions that must be met before a changeset executes. +- **Contexts**: Labels that allow conditional execution of changesets based on environment. +- **Labels**: Another filtering mechanism for changeset execution. + +### Supported Database Operations + +- DDL: CREATE, ALTER, DROP for tables, views, indexes, sequences +- DML: INSERT, UPDATE, DELETE data +- Stored procedures and functions +- Custom SQL execution +- Database-specific operations + +## Project Version + +- Current Version: 3.5.2-SNAPSHOT +- Group ID: org.liquibase +- License: Apache License 2.0 + +## Key Features + +1. **Multi-format Changelog Support**: XML, YAML, JSON, SQL +2. **Database Abstraction**: Single changelog works across multiple database platforms +3. **Rollback Support**: Automatic and custom rollback generation +4. **Diff Generation**: Compare databases and generate migration scripts +5. **DBDoc Generation**: Generate database documentation +6. **Maven Plugin Integration**: Run migrations from Maven builds +7. **Ant Task Integration**: Run migrations from Ant builds +8. **Spring Integration**: Integrate with Spring applications +9. **Servlet Listener**: Auto-run migrations on web application startup +10. **OSGi Bundle**: Deploy in OSGi containers + +## Integration Points + +### Build Tool Integration +- Maven Plugin (liquibase-maven-plugin) +- Ant Tasks (liquibase-core integration.ant package) + +### Framework Integration +- Spring Framework (SpringLiquibase) +- Servlet Container (LiquibaseServletListener) +- OSGi (Bundle manifest in liquibase-core) + +### Database Support +The core supports multiple databases through the `liquibase.database` package. Each database has a specific implementation that handles dialect differences. + +## Constraints + +- **Java Version**: Requires Java 21+ +- **Maven Version**: Requires Maven 3.8.4+ +- **Database Compatibility**: Must maintain backwards compatibility with existing changelog formats +- **Thread Safety**: Lock mechanism ensures single-node execution + +## Design Decisions + +1. **Service Locator Pattern**: Uses `ServiceLocator` for plugin discovery and extensibility +2. **Changelog Parsing**: Supports multiple formats through parser abstraction +3. **Database Abstraction**: All database operations go through `Database` interface +4. **Statement/Generator Pattern**: SQL generation separated from execution +5. **Snapshot Mechanism**: Captures database state for comparison and diff operations + +--- + +## Module-Specific Context + +### liquibase-core Module + +**Purpose**: Core engine containing all database migration functionality. + +**Key Packages:** +- `liquibase.change`: Change types (AddColumn, CreateTable, etc.) +- `liquibase.changelog`: Changelog parsing and management +- `liquibase.database`: Database abstraction layer +- `liquibase.diff`: Database comparison and diff generation +- `liquibase.executor`: SQL execution layer +- `liquibase.lockservice`: Distributed lock management +- `liquibase.parser`: Changelog file parsing +- `liquibase.precondition`: Pre-execution condition checks +- `liquibase.snapshot`: Database state capture +- `liquibase.sql`: SQL statement types +- `liquibase.sqlgenerator`: Database-specific SQL generation + +**Dependencies:** +- commons-cli:1.9.0 (optional, for CLI) +- snakeyaml:2.4 (YAML changelog support) +- ant:1.10.15 (provided, for Ant integration) +- osgi.core:8.0.0 (provided, for OSGi support) +- servlet-api:2.4 (provided, for servlet integration) +- spring:2.0.6 (provided, for Spring integration) + +**Build Artifacts:** +- Main JAR: liquibase-{version}.jar +- Test JAR: liquibase-{version}-tests.jar +- OSGi Bundle (with manifest) +- Distribution archive (bin.xml assembly) + +### liquibase-maven-plugin Module + +**Purpose**: Maven plugin that wraps Liquibase functionality for Maven builds. + +**Key Features:** +- Goal-based execution (update, rollback, generateChangeLog, etc.) +- Project classpath integration +- Maven property interpolation + +**Dependencies:** +- maven-plugin-api:3.9.10 +- maven-core:3.9.10 +- maven-compat:3.9.10 +- liquibase-core + +**Usage:** +```xml + + org.liquibase + liquibase-maven-plugin + 3.5.2-SNAPSHOT + + src/main/resources/db/changelog.xml + jdbc:postgresql://localhost/mydb + + +``` + +### liquibase-integration-tests Module + +**Purpose**: Integration tests that verify Liquibase functionality against real databases. + +**Key Features:** +- Tests against multiple databases (H2, HSQLDB, Derby) +- Maven Verifier tests for plugin verification +- Ant integration tests +- Spock-based tests for behavior verification + +**Test Dependencies:** +- junit-jupiter:5.13.3 +- spock-core:2.4-M6-groovy-4.0 +- hsqldb:2.7.4 +- derby:10.17.1.0 +- maven-verifier:2.0.0-M1 + +**Profiles:** +- `oracle`: Adds Oracle JDBC driver (ojdbc8:18.3.0.0) for Oracle testing + +**Note**: This module has `packaging: pom` since it only contains tests, no main source code. + +### liquibase-debian Module + +**Purpose**: Builds Debian (.deb) package for Linux distribution. + +**Activation**: Only activated on Linux systems (`linux`). + +### liquibase-rpm Module + +**Purpose**: Builds RPM package for Red Hat/CentOS distribution. + +**Activation**: Only activated on Linux systems (`linux`). diff --git a/docs/tickets/.gitignore b/docs/tickets/.gitignore new file mode 100644 index 0000000000..c1ae97641e --- /dev/null +++ b/docs/tickets/.gitignore @@ -0,0 +1,2 @@ +# Ticket-specific context files (engineer-specific, temporary) +*.md diff --git a/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlChangeLogParser.java b/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlChangeLogParser.java index 2f80a4d57e..261506a801 100644 --- a/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlChangeLogParser.java +++ b/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlChangeLogParser.java @@ -5,12 +5,11 @@ import liquibase.changelog.ChangeLogParameters; import liquibase.changelog.DatabaseChangeLog; import liquibase.exception.ChangeLogParseException; -import liquibase.logging.LogFactory; -import liquibase.logging.Logger; import liquibase.parser.ChangeLogParser; import liquibase.parser.core.ParsedNode; import liquibase.resource.ResourceAccessor; import liquibase.util.StreamUtil; +import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import java.io.InputStream; @@ -20,7 +19,9 @@ public class YamlChangeLogParser extends YamlParser implements ChangeLogParser { @Override public DatabaseChangeLog parse(String physicalChangeLogLocation, ChangeLogParameters changeLogParameters, ResourceAccessor resourceAccessor) throws ChangeLogParseException { - Yaml yaml = new Yaml(); + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setCodePointLimit(Integer.MAX_VALUE); + Yaml yaml = new Yaml(loaderOptions); try { InputStream changeLogStream = StreamUtil.singleInputStream(physicalChangeLogLocation, resourceAccessor); diff --git a/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlSnapshotParser.java b/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlSnapshotParser.java index 9ad2ce7357..bf05854622 100644 --- a/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlSnapshotParser.java +++ b/liquibase-core/src/main/java/liquibase/parser/core/yaml/YamlSnapshotParser.java @@ -10,22 +10,23 @@ import liquibase.parser.core.ParsedNode; import liquibase.resource.ResourceAccessor; import liquibase.snapshot.DatabaseSnapshot; -import liquibase.snapshot.EmptyDatabaseSnapshot; import liquibase.snapshot.RestoredDatabaseSnapshot; import liquibase.util.StreamUtil; +import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; +import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.IOException; -import java.util.List; import java.util.Map; public class YamlSnapshotParser extends YamlParser implements SnapshotParser { @Override public DatabaseSnapshot parse(String path, ResourceAccessor resourceAccessor) throws LiquibaseParseException { - Yaml yaml = new Yaml(); + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setCodePointLimit(Integer.MAX_VALUE); + Yaml yaml = new Yaml(loaderOptions); try { InputStream stream = StreamUtil.singleInputStream(path, resourceAccessor); diff --git a/liquibase-core/src/test/java/liquibase/parser/core/yaml/YamlChangeLogParserTest.java b/liquibase-core/src/test/java/liquibase/parser/core/yaml/YamlChangeLogParserTest.java new file mode 100644 index 0000000000..e4dc546b9f --- /dev/null +++ b/liquibase-core/src/test/java/liquibase/parser/core/yaml/YamlChangeLogParserTest.java @@ -0,0 +1,292 @@ +package liquibase.parser.core.yaml; + +import liquibase.changelog.ChangeLogParameters; +import liquibase.changelog.DatabaseChangeLog; +import liquibase.exception.ChangeLogParseException; +import liquibase.resource.ClassLoaderResourceAccessor; +import liquibase.resource.ResourceAccessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link YamlChangeLogParser}. + */ +public class YamlChangeLogParserTest { + + private YamlChangeLogParser parser; + private ChangeLogParameters changeLogParameters; + + @BeforeEach + void setUp() { + parser = new YamlChangeLogParser(); + changeLogParameters = new ChangeLogParameters(); + } + + @Test + void supports_withYamlExtension_returnsTrue() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + assertTrue(parser.supports("test.yaml", resourceAccessor)); + assertTrue(parser.supports("test.yml", resourceAccessor)); + assertTrue(parser.supports("path/to/changelog.yaml", resourceAccessor)); + assertTrue(parser.supports("path/to/changelog.yml", resourceAccessor)); + } + + @Test + void supports_withNonYamlExtension_returnsFalse() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + assertFalse(parser.supports("test.xml", resourceAccessor)); + assertFalse(parser.supports("test.json", resourceAccessor)); + assertFalse(parser.supports("test.sql", resourceAccessor)); + assertFalse(parser.supports("test.txt", resourceAccessor)); + } + + @Test + void supports_withUpperCaseExtension_returnsTrue() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + assertTrue(parser.supports("test.YAML", resourceAccessor)); + assertTrue(parser.supports("test.YML", resourceAccessor)); + assertTrue(parser.supports("test.Yaml", resourceAccessor)); + } + + @Test + void getPriority_default_returnsDefaultPriority() { + int priority = parser.getPriority(); + + assertEquals(YamlChangeLogParser.PRIORITY_DEFAULT, priority); + } + + @Test + void parse_withNonExistentFile_throwsChangeLogParseException() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + ChangeLogParseException exception = assertThrows(ChangeLogParseException.class, () -> { + parser.parse("non-existent-file.yaml", changeLogParameters, resourceAccessor); + }); + + assertTrue(exception.getMessage().contains("does not exist")); + } + + @Test + void parse_withEmptyFile_throwsChangeLogParseException() { + String emptyContent = ""; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", emptyContent); + + assertThrows(ChangeLogParseException.class, () -> { + parser.parse("test.yaml", changeLogParameters, resourceAccessor); + }); + } + + @Test + void parse_withMissingDatabaseChangeLogNode_throwsChangeLogParseException() { + String yamlContent = "someKey: someValue\nanotherKey: anotherValue"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", yamlContent); + + ChangeLogParseException exception = assertThrows(ChangeLogParseException.class, () -> { + parser.parse("test.yaml", changeLogParameters, resourceAccessor); + }); + + assertTrue(exception.getMessage().contains("databaseChangeLog")); + } + + @Test + void parse_withInvalidYamlSyntax_throwsChangeLogParseException() { + String invalidYaml = "databaseChangeLog:\n - invalid: [unclosed bracket"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", invalidYaml); + + ChangeLogParseException exception = assertThrows(ChangeLogParseException.class, () -> { + parser.parse("test.yaml", changeLogParameters, resourceAccessor); + }); + + assertTrue(exception.getMessage().contains("Syntax error")); + } + + @Test + void parse_withDatabaseChangeLogNotAList_throwsChangeLogParseException() { + String yamlContent = "databaseChangeLog: notAList"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", yamlContent); + + assertThrows(ChangeLogParseException.class, () -> { + parser.parse("test.yaml", changeLogParameters, resourceAccessor); + }); + } + + @Test + void parse_withValidEmptyChangeLog_returnsDatabaseChangeLog() throws Exception { + String validChangeLog = "databaseChangeLog: []"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", validChangeLog); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withValidChangeLog_setsPhysicalFilePath() throws Exception { + String validChangeLog = "databaseChangeLog: []"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("path/to/changelog.yaml", validChangeLog); + + DatabaseChangeLog result = parser.parse("path/to/changelog.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + assertEquals("path/to/changelog.yaml", result.getPhysicalFilePath()); + } + + @Test + void parse_withSingleProperty_parsesPropertySuccessfully() throws Exception { + String changeLogWithProperty = "databaseChangeLog:\n" + + " - property:\n" + + " name: testProp\n" + + " value: testValue"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", changeLogWithProperty); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withMultipleProperties_parsesAllProperties() throws Exception { + String changeLogWithProperties = "databaseChangeLog:\n" + + " - property:\n" + + " name: prop1\n" + + " value: value1\n" + + " - property:\n" + + " name: prop2\n" + + " value: value2"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", changeLogWithProperties); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withPropertyGlobalTrue_parsesSuccessfully() throws Exception { + String changeLogWithGlobalProperty = "databaseChangeLog:\n" + + " - property:\n" + + " name: globalProp\n" + + " value: globalValue\n" + + " global: true"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", changeLogWithGlobalProperty); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withPropertyGlobalFalse_parsesSuccessfully() throws Exception { + String changeLogWithLocalProperty = "databaseChangeLog:\n" + + " - property:\n" + + " name: localProp\n" + + " value: localValue\n" + + " global: false"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", changeLogWithLocalProperty); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withPropertyContext_parsesSuccessfully() throws Exception { + String changeLogWithContextProperty = "databaseChangeLog:\n" + + " - property:\n" + + " name: contextProp\n" + + " value: contextValue\n" + + " context: test"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", changeLogWithContextProperty); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withPropertyLabels_parsesSuccessfully() throws Exception { + String changeLogWithLabelsProperty = "databaseChangeLog:\n" + + " - property:\n" + + " name: labeledProp\n" + + " value: labeledValue\n" + + " labels: dev"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", changeLogWithLabelsProperty); + + DatabaseChangeLog result = parser.parse("test.yaml", changeLogParameters, resourceAccessor); + + assertNotNull(result); + } + + @Test + void parse_withLargeContent_doesNotFailDueToCodePointLimit() { + StringBuilder largeContent = new StringBuilder(); + largeContent.append("databaseChangeLog:\n"); + + String paddedValue = String.join("", Collections.nCopies(100, "x")); + for (int i = 0; i < 35000; i++) { + largeContent.append(" - property:\n"); + largeContent.append(" name: prop").append(i).append("\n"); + largeContent.append(" value: ").append(paddedValue).append("\n"); + } + + ResourceAccessor resourceAccessor = createMockResourceAccessor("large-changelog.yaml", largeContent.toString()); + + try { + parser.parse("large-changelog.yaml", changeLogParameters, resourceAccessor); + } catch (ChangeLogParseException e) { + assertFalse(e.getMessage().contains("exceeds the limit"), + "Parser should handle large YAML files without code point limit error"); + assertFalse(e.getMessage().contains("codePointLimit"), + "Parser should handle large YAML files without code point limit error"); + } + } + + @Test + void parse_withNullChangeLogParameters_handlesGracefully() throws Exception { + String validChangeLog = "databaseChangeLog: []"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", validChangeLog); + + DatabaseChangeLog result = parser.parse("test.yaml", null, resourceAccessor); + + assertNotNull(result); + } + + /** + * Creates a mock ResourceAccessor that returns the specified content for the given path. + */ + private ResourceAccessor createMockResourceAccessor(String path, String content) { + return new ResourceAccessor() { + @Override + public Set getResourcesAsStream(String requestedPath) throws IOException { + if (requestedPath.equals(path)) { + return Collections.singleton( + new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)) + ); + } + return Collections.emptySet(); + } + + @Override + public Set list(String relativeTo, String listPath, boolean includeFiles, + boolean includeDirectories, boolean recursive) throws IOException { + return Collections.emptySet(); + } + + @Override + public ClassLoader toClassLoader() { + return this.getClass().getClassLoader(); + } + }; + } +} diff --git a/liquibase-core/src/test/java/liquibase/parser/core/yaml/YamlSnapshotParserTest.java b/liquibase-core/src/test/java/liquibase/parser/core/yaml/YamlSnapshotParserTest.java new file mode 100644 index 0000000000..c26bac0733 --- /dev/null +++ b/liquibase-core/src/test/java/liquibase/parser/core/yaml/YamlSnapshotParserTest.java @@ -0,0 +1,207 @@ +package liquibase.parser.core.yaml; + +import liquibase.exception.LiquibaseParseException; +import liquibase.resource.ClassLoaderResourceAccessor; +import liquibase.resource.ResourceAccessor; +import liquibase.snapshot.DatabaseSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link YamlSnapshotParser}. + */ +public class YamlSnapshotParserTest { + + private YamlSnapshotParser parser; + + @BeforeEach + void setUp() { + parser = new YamlSnapshotParser(); + } + + @Test + void supports_withYamlExtension_returnsTrue() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + assertTrue(parser.supports("test.yaml", resourceAccessor)); + assertTrue(parser.supports("test.yml", resourceAccessor)); + assertTrue(parser.supports("path/to/snapshot.yaml", resourceAccessor)); + assertTrue(parser.supports("path/to/snapshot.yml", resourceAccessor)); + } + + @Test + void supports_withNonYamlExtension_returnsFalse() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + assertFalse(parser.supports("test.xml", resourceAccessor)); + assertFalse(parser.supports("test.json", resourceAccessor)); + assertFalse(parser.supports("test.sql", resourceAccessor)); + assertFalse(parser.supports("test.txt", resourceAccessor)); + } + + @Test + void supports_withUpperCaseExtension_returnsTrue() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + assertTrue(parser.supports("test.YAML", resourceAccessor)); + assertTrue(parser.supports("test.YML", resourceAccessor)); + assertTrue(parser.supports("test.Yaml", resourceAccessor)); + } + + @Test + void getPriority_default_returnsDefaultPriority() { + int priority = parser.getPriority(); + + assertEquals(YamlSnapshotParser.PRIORITY_DEFAULT, priority); + } + + @Test + void parse_withNonExistentFile_throwsLiquibaseParseException() { + ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor(); + + LiquibaseParseException exception = assertThrows(LiquibaseParseException.class, () -> { + parser.parse("non-existent-file.yaml", resourceAccessor); + }); + + assertTrue(exception.getMessage().contains("does not exist")); + } + + @Test + void parse_withMissingSnapshotNode_throwsLiquibaseParseException() { + String yamlContent = "someKey: someValue\nanotherKey: anotherValue"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", yamlContent); + + LiquibaseParseException exception = assertThrows(LiquibaseParseException.class, () -> { + parser.parse("test.yaml", resourceAccessor); + }); + + assertTrue(exception.getMessage().contains("Could not find root snapshot node")); + } + + @Test + void parse_withInvalidYamlSyntax_throwsLiquibaseParseException() { + String invalidYaml = "snapshot:\n - invalid: [unclosed bracket"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", invalidYaml); + + LiquibaseParseException exception = assertThrows(LiquibaseParseException.class, () -> { + parser.parse("test.yaml", resourceAccessor); + }); + + assertTrue(exception.getMessage().contains("Syntax error")); + } + + @Test + void parse_withEmptyFile_throwsLiquibaseParseException() { + String emptyContent = ""; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", emptyContent); + + assertThrows(LiquibaseParseException.class, () -> { + parser.parse("test.yaml", resourceAccessor); + }); + } + + @Test + void parse_withValidSnapshot_returnsDatabaseSnapshot() { + String validSnapshot = "snapshot:\n" + + " database:\n" + + " shortName: h2\n" + + " objects: {}\n"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", validSnapshot); + + try { + DatabaseSnapshot result = parser.parse("test.yaml", resourceAccessor); + assertNotNull(result); + } catch (LiquibaseParseException e) { + assertFalse(e.getMessage().contains("Syntax error")); + } + } + + @Test + void parse_withMetadata_parsesMetadataSuccessfully() { + String snapshotWithMetadata = "snapshot:\n" + + " database:\n" + + " shortName: h2\n" + + " metadata:\n" + + " key1: value1\n" + + " key2: value2\n" + + " objects: {}\n"; + ResourceAccessor resourceAccessor = createMockResourceAccessor("test.yaml", snapshotWithMetadata); + + try { + DatabaseSnapshot result = parser.parse("test.yaml", resourceAccessor); + assertNotNull(result); + assertNotNull(result.getMetadata()); + } catch (LiquibaseParseException e) { + assertFalse(e.getMessage().contains("Syntax error")); + } + } + + @Test + void parse_withLargeContent_doesNotFailDueToCodePointLimit() { + StringBuilder largeContent = new StringBuilder(); + largeContent.append("snapshot:\n"); + largeContent.append(" database:\n"); + largeContent.append(" shortName: h2\n"); + largeContent.append(" objects:\n"); + + String paddedValue = String.join("", Collections.nCopies(100, "x")); + for (int i = 0; i < 35000; i++) { + largeContent.append(" key").append(i).append(": ").append(paddedValue).append("\n"); + } + + ResourceAccessor resourceAccessor = createMockResourceAccessor("large-snapshot.yaml", largeContent.toString()); + + try { + parser.parse("large-snapshot.yaml", resourceAccessor); + } catch (LiquibaseParseException e) { + assertFalse(e.getMessage().contains("exceeds the limit"), + "Parser should handle large YAML files without code point limit error"); + assertFalse(e.getMessage().contains("codePointLimit"), + "Parser should handle large YAML files without code point limit error"); + } + } + + @Test + void parse_withNullResourceAccessor_throwsLiquibaseParseException() { + assertThrows(LiquibaseParseException.class, () -> { + parser.parse("test.yaml", null); + }); + } + + /** + * Creates a mock ResourceAccessor that returns the specified content for the given path. + */ + private ResourceAccessor createMockResourceAccessor(String path, String content) { + return new ResourceAccessor() { + @Override + public Set getResourcesAsStream(String requestedPath) throws IOException { + if (requestedPath.equals(path)) { + return Collections.singleton( + new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)) + ); + } + return Collections.emptySet(); + } + + @Override + public Set list(String relativeTo, String listPath, boolean includeFiles, + boolean includeDirectories, boolean recursive) throws IOException { + return Collections.emptySet(); + } + + @Override + public ClassLoader toClassLoader() { + return this.getClass().getClassLoader(); + } + }; + } +} diff --git a/liquibase-core/src/test/java/liquibase/serializer/core/yaml/YamlSerializerTest.java b/liquibase-core/src/test/java/liquibase/serializer/core/yaml/YamlSerializerTest.java new file mode 100644 index 0000000000..deac6bf38f --- /dev/null +++ b/liquibase-core/src/test/java/liquibase/serializer/core/yaml/YamlSerializerTest.java @@ -0,0 +1,108 @@ +package liquibase.serializer.core.yaml; + +import liquibase.change.ColumnConfig; +import liquibase.change.core.CreateTableChange; +import liquibase.changelog.ChangeSet; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link YamlSerializer} abstract class functionality. + * Tests are performed through concrete implementation {@link YamlChangeLogSerializer}. + */ +public class YamlSerializerTest { + + @Test + void getValidFileExtensions_default_returnsYamlAndYml() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + + String[] extensions = serializer.getValidFileExtensions(); + + assertNotNull(extensions); + assertEquals(2, extensions.length); + assertEquals("yaml", extensions[0]); + assertEquals("yml", extensions[1]); + } + + @Test + void serialize_withChangeSet_returnsYamlString() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + ChangeSet changeSet = new ChangeSet("test1", "testAuthor", false, true, "/test/path.xml", null, null, null); + CreateTableChange change = new CreateTableChange(); + change.setTableName("testTable"); + change.addColumn(new ColumnConfig().setName("id").setType("int")); + changeSet.addChange(change); + + String result = serializer.serialize(changeSet, false); + + assertNotNull(result); + assertTrue(result.contains("changeSet")); + assertTrue(result.contains("testTable")); + assertTrue(result.contains("id")); + } + + @Test + void serialize_withPrettyPrintOption_returnsOutput() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + ChangeSet changeSet = new ChangeSet("test1", "testAuthor", false, true, "/test/path.xml", null, null, null); + CreateTableChange change = new CreateTableChange(); + change.setTableName("testTable"); + changeSet.addChange(change); + + String prettyResult = serializer.serialize(changeSet, true); + String normalResult = serializer.serialize(changeSet, false); + + assertNotNull(prettyResult); + assertNotNull(normalResult); + } + + @Test + void serialize_withEmptyChangeSet_returnsYamlWithChangeSet() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + ChangeSet changeSet = new ChangeSet("empty", "testAuthor", false, true, "/test/path.xml", null, null, null); + + String result = serializer.serialize(changeSet, false); + + assertNotNull(result); + assertTrue(result.contains("changeSet")); + } + + @Test + void serialize_withMultipleColumns_returnsAllColumnsInYaml() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + ChangeSet changeSet = new ChangeSet("multi", "testAuthor", false, true, "/test/path.xml", null, null, null); + CreateTableChange change = new CreateTableChange(); + change.setTableName("users"); + change.addColumn(new ColumnConfig().setName("id").setType("int")); + change.addColumn(new ColumnConfig().setName("username").setType("varchar(100)")); + change.addColumn(new ColumnConfig().setName("email").setType("varchar(255)")); + changeSet.addChange(change); + + String result = serializer.serialize(changeSet, false); + + assertNotNull(result); + assertTrue(result.contains("users")); + assertTrue(result.contains("id")); + assertTrue(result.contains("username")); + assertTrue(result.contains("email")); + } + + @Test + void getValidFileExtensions_default_doesNotReturnJson() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + + String[] extensions = serializer.getValidFileExtensions(); + + assertNotEquals("json", extensions[0]); + } + + @Test + void serialize_withNullObject_throwsNullPointerException() { + YamlChangeLogSerializer serializer = new YamlChangeLogSerializer(); + + assertThrows(NullPointerException.class, () -> { + serializer.serialize(null, false); + }); + } +} diff --git a/liquibase-core/src/test/java/liquibase/serializer/core/yaml/YamlSnapshotSerializerTest.java b/liquibase-core/src/test/java/liquibase/serializer/core/yaml/YamlSnapshotSerializerTest.java new file mode 100644 index 0000000000..4883527b67 --- /dev/null +++ b/liquibase-core/src/test/java/liquibase/serializer/core/yaml/YamlSnapshotSerializerTest.java @@ -0,0 +1,140 @@ +package liquibase.serializer.core.yaml; + +import liquibase.serializer.SnapshotSerializer; +import liquibase.structure.core.Catalog; +import liquibase.structure.core.Schema; +import liquibase.structure.core.Table; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link YamlSnapshotSerializer}. + * + * Note: Tests involving DatabaseSnapshot serialization require a fully initialized + * database connection which is not available in unit tests. Those scenarios are + * better tested in integration tests. + */ +public class YamlSnapshotSerializerTest { + + private YamlSnapshotSerializer serializer; + + @BeforeEach + void setUp() { + serializer = new YamlSnapshotSerializer(); + } + + @Test + void getValidFileExtensions_default_returnsYamlAndYml() { + String[] extensions = serializer.getValidFileExtensions(); + + assertNotNull(extensions); + assertEquals(2, extensions.length); + assertEquals("yaml", extensions[0]); + assertEquals("yml", extensions[1]); + } + + @Test + void getPriority_default_returnsDefaultPriority() { + int priority = serializer.getPriority(); + + assertEquals(SnapshotSerializer.PRIORITY_DEFAULT, priority); + } + + @Test + void serialize_withTableObject_returnsYamlContainingTable() { + Table table = new Table(); + table.setName("TEST_TABLE"); + table.setSnapshotId("table-123"); + + String result = serializer.serialize(table, false); + + assertNotNull(result); + assertTrue(result.contains("table")); + assertTrue(result.contains("TEST_TABLE")); + } + + @Test + void serialize_withSchemaObject_returnsYamlContainingSchema() { + Schema schema = new Schema(); + schema.setName("PUBLIC"); + schema.setSnapshotId("schema-456"); + + String result = serializer.serialize(schema, false); + + assertNotNull(result); + assertTrue(result.contains("schema")); + } + + @Test + void serialize_withCatalogObject_returnsYamlContainingCatalog() { + Catalog catalog = new Catalog(); + catalog.setName("TEST_CATALOG"); + catalog.setSnapshotId("catalog-789"); + + String result = serializer.serialize(catalog, false); + + assertNotNull(result); + assertTrue(result.contains("catalog")); + } + + @Test + void serialize_withPrettyOption_returnsOutput() { + Table table = new Table(); + table.setName("TEST_TABLE"); + table.setSnapshotId("table-001"); + + String prettyResult = serializer.serialize(table, true); + String normalResult = serializer.serialize(table, false); + + assertNotNull(prettyResult); + assertNotNull(normalResult); + assertTrue(prettyResult.contains("table")); + assertTrue(normalResult.contains("table")); + } + + @Test + void serialize_withTableAndSchema_returnsYamlContainingBoth() { + Schema schema = new Schema(); + schema.setName("MY_SCHEMA"); + schema.setSnapshotId("schema-100"); + + Table table = new Table(); + table.setName("USERS"); + table.setSnapshotId("table-200"); + table.setSchema(schema); + + String result = serializer.serialize(table, false); + + assertNotNull(result); + assertTrue(result.contains("table")); + assertTrue(result.contains("USERS")); + } + + @Test + void serialize_withMultipleTables_returnsYamlForEach() { + Table table1 = new Table(); + table1.setName("TABLE_ONE"); + table1.setSnapshotId("t1"); + + Table table2 = new Table(); + table2.setName("TABLE_TWO"); + table2.setSnapshotId("t2"); + + String result1 = serializer.serialize(table1, false); + String result2 = serializer.serialize(table2, false); + + assertNotNull(result1); + assertNotNull(result2); + assertTrue(result1.contains("TABLE_ONE")); + assertTrue(result2.contains("TABLE_TWO")); + } + + @Test + void serialize_withNullObject_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> { + serializer.serialize(null, false); + }); + } +}