diff --git a/README.md b/README.md index 0bf3ec6..03d48be 100644 --- a/README.md +++ b/README.md @@ -770,10 +770,12 @@ and when the migration is committed or watched, the contents of `myfunction.sql` will be included in the result, such that the following SQL is executed: ```sql +--! Included functions/myfunction.sql create or replace function myfunction(a int, b int) returns int as $$ select a + b; $$ language sql stable; +--! EndIncluded functions/myfunction.sql drop policy if exists access_by_numbers on mytable; create policy access_by_numbers on mytable for update using (myfunction(4, 2) < 42); ``` diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 1a5af27..31e3c90 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -266,6 +266,15 @@ export const makeMigrations = (commitMessage?: string) => { commitMessage ? `\n--! Message: ${commitMessage}` : `` }\n\n${MIGRATION_NOTRX_TEXT.trim()}\n`; + const MIGRATION_INCLUDE_TEXT = `--!include foo.sql`; + const MIGRATION_INCLUDE_COMPILED = `${MIGRATION_INCLUDE_TEXT}\n${MIGRATION_1_TEXT}\n${MIGRATION_INCLUDE_TEXT}`; + const MIGRATION_INCLUDE_HASH = createHash("sha1") + .update(`${MIGRATION_INCLUDE_COMPILED.trim()}` + "\n") + .digest("hex"); + const MIGRATION_INCLUDE_COMMITTED = `--! Previous: -\n--! Hash: sha1:${MIGRATION_INCLUDE_HASH}${ + commitMessage ? `\n--! Message: ${commitMessage}` : `` + }\n\n${MIGRATION_INCLUDE_COMPILED}\n`; + const MIGRATION_MULTIFILE_FILES = { "migrations/links/two.sql": "select 2;", "migrations/current": { @@ -308,6 +317,9 @@ select 3; MIGRATION_NOTRX_TEXT, MIGRATION_NOTRX_HASH, MIGRATION_NOTRX_COMMITTED, + MIGRATION_INCLUDE_TEXT, + MIGRATION_INCLUDE_HASH, + MIGRATION_INCLUDE_COMMITTED, MIGRATION_MULTIFILE_TEXT, MIGRATION_MULTIFILE_HASH, MIGRATION_MULTIFILE_COMMITTED, diff --git a/__tests__/include.test.ts b/__tests__/include.test.ts index adafe55..60c7c8f 100644 --- a/__tests__/include.test.ts +++ b/__tests__/include.test.ts @@ -42,7 +42,9 @@ it("compiles an included file", async () => { FAKE_VISITED, ), ).toEqual(`\ +--! Include foo.sql select * from foo; +--! EndInclude foo.sql `); }); @@ -64,9 +66,17 @@ it("compiles multiple included files", async () => { FAKE_VISITED, ), ).toEqual(`\ +--! Include dir1/foo.sql select * from foo; +--! EndInclude dir1/foo.sql +--! Include dir2/bar.sql select * from bar; +--! EndInclude dir2/bar.sql +--! Include dir3/baz.sql +--! Include dir4/qux.sql select * from qux; +--! EndInclude dir4/qux.sql +--! EndInclude dir3/baz.sql `); }); @@ -129,6 +139,7 @@ commit; FAKE_VISITED, ), ).toEqual(`\ +--! Include foo.sql begin; create or replace function current_user_id() returns uuid as $$ @@ -140,6 +151,6 @@ comment on function current_user_id is E'The ID of the current user.'; grant all on function current_user_id to :DATABASE_USER; commit; - +--! EndInclude foo.sql `); }); diff --git a/__tests__/readCurrentMigration.test.ts b/__tests__/readCurrentMigration.test.ts index 6e0efb5..e6736a5 100644 --- a/__tests__/readCurrentMigration.test.ts +++ b/__tests__/readCurrentMigration.test.ts @@ -111,5 +111,8 @@ it("reads from current.sql, and processes included files", async () => { const currentLocation = await getCurrentMigrationLocation(parsedSettings); const content = await readCurrentMigration(parsedSettings, currentLocation); - expect(content).toEqual("-- TEST from foo"); + expect(content).toEqual(`\ +--! Included foo_current.sql +-- TEST from foo +--! EndIncluded foo_current.sql`); }); diff --git a/__tests__/uncommit.test.ts b/__tests__/uncommit.test.ts index a57031c..10dbe33 100644 --- a/__tests__/uncommit.test.ts +++ b/__tests__/uncommit.test.ts @@ -55,6 +55,8 @@ describe.each([[undefined], ["My Commit Message"]])( const { MIGRATION_1_TEXT, MIGRATION_1_COMMITTED, + MIGRATION_INCLUDE_TEXT, + MIGRATION_INCLUDE_COMMITTED, MIGRATION_MULTIFILE_COMMITTED, MIGRATION_MULTIFILE_FILES, } = makeMigrations(commitMessage); @@ -88,6 +90,36 @@ describe.each([[undefined], ["My Commit Message"]])( ).toEqual(MIGRATION_1_COMMITTED); }); + it("rolls back a migration that has included another file", async () => { + mockFs({ + [`migrations/committed/000001${commitMessageSlug}.sql`]: + MIGRATION_INCLUDE_COMMITTED, + "migrations/current.sql": "-- JUST A COMMENT\n", + "migrations/fixtures/foo.sql": MIGRATION_1_TEXT, + }); + await migrate(settings); + await uncommit(settings); + + await expect( + fsp.stat("migrations/committed/000001.sql"), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await fsp.readFile("migrations/current.sql", "utf8")).toEqual( + (commitMessage ? `--! Message: ${commitMessage}\n\n` : "") + + MIGRATION_INCLUDE_TEXT.trim() + + "\n", + ); + + await commit(settings); + expect( + await fsp.readFile( + `migrations/committed/000001${commitMessageSlug}.sql`, + "utf8", + ), + ).toEqual(MIGRATION_INCLUDE_COMMITTED); + }); + it("rolls back multifile migration", async () => { mockFs({ [`migrations/committed/000001${commitMessageSlug}.sql`]: diff --git a/src/commands/uncommit.ts b/src/commands/uncommit.ts index 01fd188..18b4658 100644 --- a/src/commands/uncommit.ts +++ b/src/commands/uncommit.ts @@ -42,9 +42,16 @@ export async function _uncommit(parsedSettings: ParsedSettings): Promise { const contents = await fsp.readFile(lastMigrationFilepath, "utf8"); const { headers, body } = parseMigrationText(lastMigrationFilepath, contents); + // Remove included migrations + const includeRegex = + /^--![ \t]*Included[ \t]+(?.*?\.sql)[ \t]*$.*?^--![ \t]*EndIncluded[ \t]*\k[ \t]*$/gms; + const decompiledBody = body.replace(includeRegex, (match) => { + return match.split("\n")[0].replace(" Included", "include"); + }); + // Drop Hash, Previous and AllowInvalidHash from headers; then write out const { Hash, Previous, AllowInvalidHash, ...otherHeaders } = headers; - const completeBody = serializeMigration(body, otherHeaders); + const completeBody = serializeMigration(decompiledBody, otherHeaders); await writeCurrentMigration(parsedSettings, currentLocation, completeBody); // Delete the migration from committed and from the DB diff --git a/src/migration.ts b/src/migration.ts index 86ad7e6..95fdb78 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -132,7 +132,7 @@ export async function compileIncludes( content: string, processedFiles: ReadonlySet, ): Promise { - const regex = /^--![ \t]*include[ \t]+(.*\.sql)[ \t]*$/gm; + const regex = /^--![ \t]*[iI]nclude[ \t]+(.*\.sql)[ \t]*$/gm; // Find all includes in this `content` const matches = [...content.matchAll(regex)]; @@ -205,10 +205,12 @@ export async function compileIncludes( // Simple string replacement for each path matched const compiledContent = content.replace( regex, - (_match, rawSqlPath: string) => { + (match, rawSqlPath: string) => { const sqlPath = sqlPathByRawSqlPath[rawSqlPath]; const content = contentBySqlPath[sqlPath]; - return content; + const included = match.replace(/^--![ \t]*include/, "--! Included"); + const endIncluded = included.replace("Included", "EndIncluded"); + return `${included}\n${content.trim()}\n${endIncluded}`; }, );