@supabase/pg-delta 1.0.0-alpha.12 → 1.0.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/connection-url.d.ts +32 -0
- package/dist/core/connection-url.js +77 -0
- package/dist/core/export/index.d.ts +2 -2
- package/dist/core/export/index.js +4 -1
- package/dist/core/integrations/integration.types.d.ts +26 -1
- package/dist/core/integrations/integration.types.js +31 -1
- package/dist/core/integrations/supabase.js +1 -0
- package/dist/core/objects/procedure/procedure.diff.js +8 -0
- package/dist/core/objects/table/changes/table.alter.js +4 -1
- package/dist/core/objects/table/table.diff.js +7 -2
- package/dist/core/plan/create.js +5 -17
- package/dist/core/plan/types.d.ts +3 -6
- package/dist/core/postgres-config.d.ts +27 -0
- package/dist/core/postgres-config.js +99 -7
- package/package.json +2 -1
- package/src/core/connection-url.test.ts +142 -0
- package/src/core/connection-url.ts +82 -0
- package/src/core/export/index.ts +13 -4
- package/src/core/integrations/integration.types.ts +59 -1
- package/src/core/integrations/supabase.ts +1 -0
- package/src/core/objects/procedure/procedure.diff.test.ts +25 -0
- package/src/core/objects/procedure/procedure.diff.ts +12 -0
- package/src/core/objects/table/changes/table.alter.test.ts +14 -0
- package/src/core/objects/table/changes/table.alter.ts +4 -1
- package/src/core/objects/table/table.diff.test.ts +55 -0
- package/src/core/objects/table/table.diff.ts +10 -2
- package/src/core/plan/create.ts +11 -27
- package/src/core/plan/types.ts +3 -6
- package/src/core/postgres-config.test.ts +241 -0
- package/src/core/postgres-config.ts +127 -16
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isIPv6, normalizeConnectionUrl } from "./connection-url.ts";
|
|
3
|
+
|
|
4
|
+
describe("isIPv6", () => {
|
|
5
|
+
describe("accepted", () => {
|
|
6
|
+
const accepted = [
|
|
7
|
+
"::",
|
|
8
|
+
"::1",
|
|
9
|
+
"1::",
|
|
10
|
+
"1:2:3:4:5:6:7:8",
|
|
11
|
+
"2406:da18:243:740f:abda:9a5c:a92d:b3c9",
|
|
12
|
+
"::ffff:192.0.2.1",
|
|
13
|
+
"fe80::AbCd",
|
|
14
|
+
"fe80::1%eth0",
|
|
15
|
+
];
|
|
16
|
+
for (const value of accepted) {
|
|
17
|
+
test(`accepts "${value}"`, () => {
|
|
18
|
+
expect(isIPv6(value)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("rejected", () => {
|
|
24
|
+
const rejected = [
|
|
25
|
+
"",
|
|
26
|
+
"2406:da18:243:740f", // only 4 groups
|
|
27
|
+
"1:2:3:4:5:6:7:8:9", // 9 groups
|
|
28
|
+
"1::2::3", // double compression
|
|
29
|
+
"gggg::1", // invalid hex
|
|
30
|
+
"1.2.3.4", // pure IPv4
|
|
31
|
+
"[::1]", // bracketed
|
|
32
|
+
"localhost",
|
|
33
|
+
"example.com",
|
|
34
|
+
":::", // malformed
|
|
35
|
+
];
|
|
36
|
+
for (const value of rejected) {
|
|
37
|
+
test(`rejects ${JSON.stringify(value)}`, () => {
|
|
38
|
+
expect(isIPv6(value)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("normalizeConnectionUrl", () => {
|
|
45
|
+
describe("normalizes percent-encoded IPv6 hosts", () => {
|
|
46
|
+
test("full 8-group IPv6 becomes bracketed", () => {
|
|
47
|
+
const input =
|
|
48
|
+
"postgresql://user:pass@2406%3Ada18%3A243%3A740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db";
|
|
49
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
50
|
+
"postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("compressed ::1 form", () => {
|
|
55
|
+
const input = "postgresql://user:pass@%3A%3A1:5432/db";
|
|
56
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
57
|
+
"postgresql://user:pass@[::1]:5432/db",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("IPv4-mapped ::ffff:192.0.2.1", () => {
|
|
62
|
+
const input = "postgresql://user:pass@%3A%3Affff%3A192.0.2.1:5432/db";
|
|
63
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
64
|
+
"postgresql://user:pass@[::ffff:192.0.2.1]:5432/db",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("mixed-case percent triples (%3a and %3A)", () => {
|
|
69
|
+
const input =
|
|
70
|
+
"postgresql://user:pass@2406%3ada18%3A243%3a740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db";
|
|
71
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
72
|
+
"postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("preserves URL-encoded password and query string", () => {
|
|
77
|
+
const input =
|
|
78
|
+
"postgresql://user:p%40ss%2Fword@%3A%3A1:5432/db?sslmode=require&application_name=pgdelta";
|
|
79
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
80
|
+
"postgresql://user:p%40ss%2Fword@[::1]:5432/db?sslmode=require&application_name=pgdelta",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("preserves fragment", () => {
|
|
85
|
+
const input = "postgresql://user:pass@%3A%3A1:5432/db#frag";
|
|
86
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
87
|
+
"postgresql://user:pass@[::1]:5432/db#frag",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("works without a port", () => {
|
|
92
|
+
const input = "postgresql://user:pass@%3A%3A1/db";
|
|
93
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
94
|
+
"postgresql://user:pass@[::1]/db",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("works without userinfo", () => {
|
|
99
|
+
const input = "postgresql://%3A%3A1:5432/db";
|
|
100
|
+
expect(normalizeConnectionUrl(input)).toBe("postgresql://[::1]:5432/db");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("works with username only (no password)", () => {
|
|
104
|
+
const input = "postgresql://user@%3A%3A1:5432/db";
|
|
105
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
106
|
+
"postgresql://user@[::1]:5432/db",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("leaves URL unchanged (guardrail)", () => {
|
|
112
|
+
test("already-bracketed IPv6", () => {
|
|
113
|
+
const input = "postgresql://user:pass@[::1]:5432/db";
|
|
114
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("IPv4 host", () => {
|
|
118
|
+
const input = "postgresql://user:pass@127.0.0.1:5432/db";
|
|
119
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("DNS hostname", () => {
|
|
123
|
+
const input = "postgresql://user:pass@db.example.com:5432/db";
|
|
124
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("percent-encoded colons that do not decode to a valid IPv6 (4 groups only)", () => {
|
|
128
|
+
const input = "postgresql://user:pass@2406%3Ada18%3A243%3A740f:5432/db";
|
|
129
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("non-colon percent-encoded character in hostname", () => {
|
|
133
|
+
const input = "postgresql://user:pass@host%2Dname:5432/db";
|
|
134
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("garbage host `%3A%3Azzz` decodes to `::zzz`, not valid IPv6", () => {
|
|
138
|
+
const input = "postgresql://user:pass@%3A%3Azzz:5432/db";
|
|
139
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection URL normalization for pg-delta.
|
|
3
|
+
*
|
|
4
|
+
* Auto-normalizes percent-encoded IPv6 hosts in PostgreSQL connection URLs.
|
|
5
|
+
* A URL like `postgresql://user:pass@2406%3Ada18%3A...%3Ab3c9:5432/db`
|
|
6
|
+
* becomes `postgresql://user:pass@[2406:da18:...:b3c9]:5432/db` before it
|
|
7
|
+
* reaches `pg-connection-string` / `pg.Pool`, so DNS resolution sees the
|
|
8
|
+
* address in its canonical bracketed form.
|
|
9
|
+
*
|
|
10
|
+
* Non-IPv6 hosts (IPv4, DNS names, already-bracketed IPv6, partial fragments
|
|
11
|
+
* that just happen to contain `%3A`) are returned verbatim.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// IPv6 detection regex vendored from ip-regex (Sindre Sorhus, MIT).
|
|
15
|
+
// https://github.com/sindresorhus/ip-regex
|
|
16
|
+
const v4 =
|
|
17
|
+
"(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
|
|
18
|
+
const v6seg = "[a-fA-F\\d]{1,4}";
|
|
19
|
+
const v6 = `
|
|
20
|
+
(?:
|
|
21
|
+
(?:${v6seg}:){7}(?:${v6seg}|:)|
|
|
22
|
+
(?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)|
|
|
23
|
+
(?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)|
|
|
24
|
+
(?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)|
|
|
25
|
+
(?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)|
|
|
26
|
+
(?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)|
|
|
27
|
+
(?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)|
|
|
28
|
+
(?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:))
|
|
29
|
+
)(?:%[0-9a-zA-Z]{1,})?
|
|
30
|
+
`
|
|
31
|
+
.replace(/\s*\/\/.*$/gm, "")
|
|
32
|
+
.replace(/\n/g, "")
|
|
33
|
+
.trim();
|
|
34
|
+
|
|
35
|
+
const V6_EXACT = new RegExp(`^${v6}$`);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return true if `value` is a valid IPv6 literal in any canonical form:
|
|
39
|
+
* full 8-group, `::` compression, or IPv4-mapped (`::ffff:1.2.3.4`).
|
|
40
|
+
* RFC 4007 zone identifiers (`fe80::1%eth0`) are accepted.
|
|
41
|
+
*/
|
|
42
|
+
export function isIPv6(value: string): boolean {
|
|
43
|
+
return typeof value === "string" && V6_EXACT.test(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a PostgreSQL connection URL so IPv6 hosts reach pg in the
|
|
48
|
+
* canonical bracketed form.
|
|
49
|
+
*
|
|
50
|
+
* If the URL's hostname contains a percent-encoded colon AND the decoded
|
|
51
|
+
* hostname is a valid IPv6 literal, the hostname is decoded and wrapped in
|
|
52
|
+
* `[...]`. All other fields (scheme, userinfo, port, path, query, fragment)
|
|
53
|
+
* are preserved byte-for-byte from the input.
|
|
54
|
+
*
|
|
55
|
+
* Any URL whose decoded hostname does not validate as IPv6 is returned
|
|
56
|
+
* verbatim, so a malformed input will surface its usual downstream error
|
|
57
|
+
* instead of being silently rewritten.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeConnectionUrl(url: string): string {
|
|
60
|
+
const urlObj = new URL(url);
|
|
61
|
+
// Cheap pre-filter: only look closer if the hostname contains a
|
|
62
|
+
// percent-encoded colon. Anything else is left entirely untouched.
|
|
63
|
+
if (!/%3[aA]/.test(urlObj.hostname)) return url;
|
|
64
|
+
|
|
65
|
+
const decodedHost = decodeURIComponent(urlObj.hostname);
|
|
66
|
+
// Authoritative validation: only normalize when the decoded string is a
|
|
67
|
+
// real IPv6 literal. Rejects partial fragments, random hostnames that
|
|
68
|
+
// happen to contain `%3A`, and any malformed input.
|
|
69
|
+
if (!isIPv6(decodedHost)) return url;
|
|
70
|
+
|
|
71
|
+
// Preserve username/password/port/path/search/hash exactly as they appear
|
|
72
|
+
// in the WHATWG URL model (these are returned already percent-encoded).
|
|
73
|
+
const scheme = `${urlObj.protocol}//`;
|
|
74
|
+
const auth = urlObj.username
|
|
75
|
+
? urlObj.password
|
|
76
|
+
? `${urlObj.username}:${urlObj.password}@`
|
|
77
|
+
: `${urlObj.username}@`
|
|
78
|
+
: "";
|
|
79
|
+
const port = urlObj.port ? `:${urlObj.port}` : "";
|
|
80
|
+
const tail = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
|
|
81
|
+
return `${scheme}${auth}[${decodedHost}]${port}${tail}`;
|
|
82
|
+
}
|
package/src/core/export/index.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Change } from "../change.types.ts";
|
|
6
6
|
import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts";
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
type Integration,
|
|
9
|
+
type ResolvedIntegration,
|
|
10
|
+
resolveIntegration,
|
|
11
|
+
} from "../integrations/integration.types.ts";
|
|
8
12
|
import type { createPlan } from "../plan/create.ts";
|
|
9
13
|
import { DEFAULT_OPTIONS } from "../plan/sql-format/constants.ts";
|
|
10
14
|
import type { SqlFormatOptions } from "../plan/sql-format/types.ts";
|
|
@@ -29,7 +33,7 @@ type PlanResult = NonNullable<Awaited<ReturnType<typeof createPlan>>>;
|
|
|
29
33
|
|
|
30
34
|
export interface ExportOptions {
|
|
31
35
|
/** Integration for custom serialization */
|
|
32
|
-
integration?: Integration
|
|
36
|
+
integration?: Pick<Integration, "serialize">;
|
|
33
37
|
/**
|
|
34
38
|
* SQL formatter options to control the output style.
|
|
35
39
|
* Merged on top of the default export options (maxWidth: 180, keywordCase: "upper").
|
|
@@ -64,7 +68,9 @@ export function exportDeclarativeSchema(
|
|
|
64
68
|
options?: ExportOptions,
|
|
65
69
|
): DeclarativeSchemaOutput {
|
|
66
70
|
const { ctx, sortedChanges } = planResult;
|
|
67
|
-
const integration = options?.integration
|
|
71
|
+
const integration = options?.integration
|
|
72
|
+
? resolveIntegration(options?.integration)
|
|
73
|
+
: {};
|
|
68
74
|
const formatOptions: SqlFormatOptions | undefined =
|
|
69
75
|
options?.formatOptions === null
|
|
70
76
|
? undefined
|
|
@@ -108,7 +114,10 @@ export function exportDeclarativeSchema(
|
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
116
|
|
|
111
|
-
function serializeChange(
|
|
117
|
+
function serializeChange(
|
|
118
|
+
change: Change,
|
|
119
|
+
integration?: ResolvedIntegration,
|
|
120
|
+
): string {
|
|
112
121
|
return integration?.serialize?.(change) ?? change.serialize();
|
|
113
122
|
}
|
|
114
123
|
|
|
@@ -1,7 +1,65 @@
|
|
|
1
|
+
import { compileFilterDSL, type FilterDSL } from "./filter/dsl.ts";
|
|
1
2
|
import type { ChangeFilter } from "./filter/filter.types.ts";
|
|
3
|
+
import { compileSerializeDSL, type SerializeDSL } from "./serialize/dsl.ts";
|
|
2
4
|
import type { ChangeSerializer } from "./serialize/serialize.types.ts";
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A resolved integration is an integration that has been compiled to a function.
|
|
8
|
+
*/
|
|
9
|
+
export type ResolvedIntegration = {
|
|
5
10
|
filter?: ChangeFilter;
|
|
6
11
|
serialize?: ChangeSerializer;
|
|
7
12
|
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A raw integration is an integration that has not been compiled to a function.
|
|
16
|
+
*/
|
|
17
|
+
export type IntegrationDSL = {
|
|
18
|
+
filter?: FilterDSL;
|
|
19
|
+
serialize?: SerializeDSL;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An integration is a raw integration that has not been compiled to a function.
|
|
24
|
+
*/
|
|
25
|
+
export type Integration = {
|
|
26
|
+
filter?: ResolvedIntegration["filter"] | IntegrationDSL["filter"];
|
|
27
|
+
serialize?: ResolvedIntegration["serialize"] | IntegrationDSL["serialize"];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve an integration either DSL or already resovled into a ResolvedIntegration.
|
|
32
|
+
* @param integration - The integration to resolve.
|
|
33
|
+
* @returns The resolved integration.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveIntegration(
|
|
36
|
+
integration: Integration,
|
|
37
|
+
): ResolvedIntegration | undefined {
|
|
38
|
+
// Determine if filter/serialize are DSL or functions, and extract DSL for storage
|
|
39
|
+
const isFilterDSL =
|
|
40
|
+
integration.filter && typeof integration.filter !== "function";
|
|
41
|
+
const isSerializeDSL =
|
|
42
|
+
integration.serialize && typeof integration.serialize !== "function";
|
|
43
|
+
const filterDSL = isFilterDSL ? (integration.filter as FilterDSL) : undefined;
|
|
44
|
+
const serializeDSL = isSerializeDSL
|
|
45
|
+
? (integration.serialize as SerializeDSL)
|
|
46
|
+
: undefined;
|
|
47
|
+
|
|
48
|
+
// Build final integration: compile DSL if needed, use functions directly otherwise
|
|
49
|
+
if (integration.filter || integration.serialize) {
|
|
50
|
+
return {
|
|
51
|
+
filter:
|
|
52
|
+
typeof integration.filter === "function"
|
|
53
|
+
? integration.filter
|
|
54
|
+
: filterDSL
|
|
55
|
+
? compileFilterDSL(filterDSL)
|
|
56
|
+
: undefined,
|
|
57
|
+
serialize:
|
|
58
|
+
typeof integration.serialize === "function"
|
|
59
|
+
? integration.serialize
|
|
60
|
+
: serializeDSL
|
|
61
|
+
? compileSerializeDSL(serializeDSL)
|
|
62
|
+
: undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
AlterProcedureSetStrictness,
|
|
10
10
|
AlterProcedureSetVolatility,
|
|
11
11
|
} from "./changes/procedure.alter.ts";
|
|
12
|
+
import { CreateCommentOnProcedure } from "./changes/procedure.comment.ts";
|
|
12
13
|
import { CreateProcedure } from "./changes/procedure.create.ts";
|
|
13
14
|
import { DropProcedure } from "./changes/procedure.drop.ts";
|
|
14
15
|
import { diffProcedures } from "./procedure.diff.ts";
|
|
@@ -158,4 +159,28 @@ describe.concurrent("procedure.diff", () => {
|
|
|
158
159
|
expect(changes).toHaveLength(1);
|
|
159
160
|
expect(changes[0]).toBeInstanceOf(CreateProcedure);
|
|
160
161
|
});
|
|
162
|
+
|
|
163
|
+
test("create or replace also emits a procedure comment when the comment changes", () => {
|
|
164
|
+
const main = new Procedure(base);
|
|
165
|
+
const branch = new Procedure({
|
|
166
|
+
...base,
|
|
167
|
+
definition:
|
|
168
|
+
"CREATE FUNCTION public.fn1() RETURNS int4 LANGUAGE sql AS $$SELECT 42::int4$$",
|
|
169
|
+
source_code: "SELECT 42::int4",
|
|
170
|
+
comment: "updated comment",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const changes = diffProcedures(
|
|
174
|
+
testContext,
|
|
175
|
+
{ [main.stableId]: main },
|
|
176
|
+
{ [branch.stableId]: branch },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(changes.some((change) => change instanceof CreateProcedure)).toBe(
|
|
180
|
+
true,
|
|
181
|
+
);
|
|
182
|
+
expect(
|
|
183
|
+
changes.some((change) => change instanceof CreateCommentOnProcedure),
|
|
184
|
+
).toBe(true);
|
|
185
|
+
});
|
|
161
186
|
});
|
|
@@ -169,6 +169,18 @@ export function diffProcedures(
|
|
|
169
169
|
changes.push(
|
|
170
170
|
new CreateProcedure({ procedure: branchProcedure, orReplace: true }),
|
|
171
171
|
);
|
|
172
|
+
|
|
173
|
+
if (mainProcedure.comment !== branchProcedure.comment) {
|
|
174
|
+
if (branchProcedure.comment === null) {
|
|
175
|
+
changes.push(
|
|
176
|
+
new DropCommentOnProcedure({ procedure: mainProcedure }),
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
changes.push(
|
|
180
|
+
new CreateCommentOnProcedure({ procedure: branchProcedure }),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
172
184
|
} else {
|
|
173
185
|
// Only alterable properties changed - check each one
|
|
174
186
|
|
|
@@ -492,6 +492,20 @@ describe.concurrent("table", () => {
|
|
|
492
492
|
"ALTER TABLE public.test_table ALTER COLUMN a SET DEFAULT 0",
|
|
493
493
|
);
|
|
494
494
|
|
|
495
|
+
const changeSetGeneratedExpression = new AlterTableAlterColumnSetDefault({
|
|
496
|
+
table: withCols,
|
|
497
|
+
column: {
|
|
498
|
+
...colText,
|
|
499
|
+
name: "computed_name",
|
|
500
|
+
is_generated: true,
|
|
501
|
+
default: "lower((b))",
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
await assertValidSql(changeSetGeneratedExpression.serialize());
|
|
505
|
+
expect(changeSetGeneratedExpression.serialize()).toBe(
|
|
506
|
+
"ALTER TABLE public.test_table ALTER COLUMN computed_name SET EXPRESSION AS (lower((b)))",
|
|
507
|
+
);
|
|
508
|
+
|
|
495
509
|
const changeDropDefault = new AlterTableAlterColumnDropDefault({
|
|
496
510
|
table: withCols,
|
|
497
511
|
column: { ...colInt, default: null },
|
|
@@ -644,6 +644,9 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
|
|
|
644
644
|
|
|
645
645
|
serialize(_options?: SerializeOptions): string {
|
|
646
646
|
const set = this.column.is_generated ? "SET EXPRESSION AS" : "SET DEFAULT";
|
|
647
|
+
const value = this.column.is_generated
|
|
648
|
+
? `(${this.column.default ?? "NULL"})`
|
|
649
|
+
: (this.column.default ?? "NULL");
|
|
647
650
|
|
|
648
651
|
return [
|
|
649
652
|
"ALTER TABLE",
|
|
@@ -651,7 +654,7 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
|
|
|
651
654
|
"ALTER COLUMN",
|
|
652
655
|
this.column.name,
|
|
653
656
|
set,
|
|
654
|
-
|
|
657
|
+
value,
|
|
655
658
|
].join(" ");
|
|
656
659
|
}
|
|
657
660
|
}
|
|
@@ -835,6 +835,61 @@ describe.concurrent("table.diff", () => {
|
|
|
835
835
|
).toBe(true);
|
|
836
836
|
});
|
|
837
837
|
|
|
838
|
+
test("postgres 17+ recreates a column when switching from regular to generated", () => {
|
|
839
|
+
const pg17Context = {
|
|
840
|
+
...testContext,
|
|
841
|
+
version: 170000,
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
const regularColumn = {
|
|
845
|
+
name: "confirmed_at",
|
|
846
|
+
position: 1,
|
|
847
|
+
data_type: "timestamp with time zone",
|
|
848
|
+
data_type_str: "timestamp with time zone",
|
|
849
|
+
is_custom_type: false,
|
|
850
|
+
custom_type_type: null,
|
|
851
|
+
custom_type_category: null,
|
|
852
|
+
custom_type_schema: null,
|
|
853
|
+
custom_type_name: null,
|
|
854
|
+
not_null: false,
|
|
855
|
+
is_identity: false,
|
|
856
|
+
is_identity_always: false,
|
|
857
|
+
is_generated: false,
|
|
858
|
+
collation: null,
|
|
859
|
+
default: null,
|
|
860
|
+
comment: null,
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const generatedColumn = {
|
|
864
|
+
...regularColumn,
|
|
865
|
+
is_generated: true,
|
|
866
|
+
default: "LEAST(email_confirmed_at, phone_confirmed_at)",
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const mainTable = new Table({
|
|
870
|
+
...base,
|
|
871
|
+
name: "auth_users_like",
|
|
872
|
+
columns: [regularColumn],
|
|
873
|
+
});
|
|
874
|
+
const branchTable = new Table({
|
|
875
|
+
...base,
|
|
876
|
+
name: "auth_users_like",
|
|
877
|
+
columns: [generatedColumn],
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const changes = diffTables(
|
|
881
|
+
pg17Context,
|
|
882
|
+
{ [mainTable.stableId]: mainTable },
|
|
883
|
+
{ [branchTable.stableId]: branchTable },
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
expect(changes.some((c) => c instanceof AlterTableDropColumn)).toBe(true);
|
|
887
|
+
expect(changes.some((c) => c instanceof AlterTableAddColumn)).toBe(true);
|
|
888
|
+
expect(
|
|
889
|
+
changes.some((c) => c instanceof AlterTableAlterColumnSetDefault),
|
|
890
|
+
).toBe(false);
|
|
891
|
+
});
|
|
892
|
+
|
|
838
893
|
test("created table with privileges emits grant changes", () => {
|
|
839
894
|
const t = new Table({
|
|
840
895
|
...base,
|
|
@@ -745,10 +745,18 @@ export function diffTables(
|
|
|
745
745
|
// Set new default value
|
|
746
746
|
const isGeneratedColumn = branchCol.is_generated;
|
|
747
747
|
const isPostgresLowerThan17 = ctx.version < 170000;
|
|
748
|
+
const generatedStatusChanged =
|
|
749
|
+
mainCol.is_generated !== branchCol.is_generated;
|
|
748
750
|
|
|
749
|
-
if (
|
|
751
|
+
if (
|
|
752
|
+
isGeneratedColumn &&
|
|
753
|
+
(isPostgresLowerThan17 || generatedStatusChanged)
|
|
754
|
+
) {
|
|
750
755
|
// For generated columns in < PostgreSQL 17, we need to drop and recreate
|
|
751
|
-
// instead of using SET EXPRESSION AS for computed columns
|
|
756
|
+
// instead of using SET EXPRESSION AS for computed columns. We also
|
|
757
|
+
// need to recreate the column when switching between regular and
|
|
758
|
+
// generated states because SET EXPRESSION only applies to existing
|
|
759
|
+
// generated columns.
|
|
752
760
|
// cf: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=5d06e99a3
|
|
753
761
|
// cf: https://www.postgresql.org/docs/release/17.0/
|
|
754
762
|
// > Allow ALTER TABLE to change a column's generation expression
|
package/src/core/plan/create.ts
CHANGED
|
@@ -10,15 +10,12 @@ import { createEmptyCatalog, extractCatalog } from "../catalog.model.ts";
|
|
|
10
10
|
import type { Change } from "../change.types.ts";
|
|
11
11
|
import type { DiffContext } from "../context.ts";
|
|
12
12
|
import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts";
|
|
13
|
+
import type { FilterDSL } from "../integrations/filter/dsl.ts";
|
|
13
14
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "../integrations/
|
|
17
|
-
import type {
|
|
18
|
-
import {
|
|
19
|
-
compileSerializeDSL,
|
|
20
|
-
type SerializeDSL,
|
|
21
|
-
} from "../integrations/serialize/dsl.ts";
|
|
15
|
+
type ResolvedIntegration,
|
|
16
|
+
resolveIntegration,
|
|
17
|
+
} from "../integrations/integration.types.ts";
|
|
18
|
+
import type { SerializeDSL } from "../integrations/serialize/dsl.ts";
|
|
22
19
|
import { createManagedPool, endPool } from "../postgres-config.ts";
|
|
23
20
|
import { sortChanges } from "../sort/sort-changes.ts";
|
|
24
21
|
import type { PgDependRow } from "../sort/types.ts";
|
|
@@ -155,23 +152,10 @@ function buildPlanForCatalogs(
|
|
|
155
152
|
: undefined;
|
|
156
153
|
|
|
157
154
|
// Build final integration: compile DSL if needed, use functions directly otherwise
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
typeof filterOption === "function"
|
|
163
|
-
? filterOption
|
|
164
|
-
: filterDSL
|
|
165
|
-
? compileFilterDSL(filterDSL)
|
|
166
|
-
: undefined,
|
|
167
|
-
serialize:
|
|
168
|
-
typeof serializeOption === "function"
|
|
169
|
-
? serializeOption
|
|
170
|
-
: serializeDSL
|
|
171
|
-
? compileSerializeDSL(serializeDSL)
|
|
172
|
-
: undefined,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
155
|
+
const finalIntegration = resolveIntegration({
|
|
156
|
+
filter: filterOption,
|
|
157
|
+
serialize: serializeOption,
|
|
158
|
+
});
|
|
175
159
|
|
|
176
160
|
// Use filter from final integration
|
|
177
161
|
const filterFn = finalIntegration?.filter;
|
|
@@ -317,7 +301,7 @@ function buildPlan(
|
|
|
317
301
|
options?: CreatePlanOptions,
|
|
318
302
|
filterDSL?: FilterDSL,
|
|
319
303
|
serializeDSL?: SerializeDSL,
|
|
320
|
-
integration?:
|
|
304
|
+
integration?: ResolvedIntegration,
|
|
321
305
|
): Plan {
|
|
322
306
|
const role = options?.role;
|
|
323
307
|
const statements = generateStatements(changes, {
|
|
@@ -350,7 +334,7 @@ function buildPlan(
|
|
|
350
334
|
function generateStatements(
|
|
351
335
|
changes: Change[],
|
|
352
336
|
options?: {
|
|
353
|
-
integration?:
|
|
337
|
+
integration?: ResolvedIntegration;
|
|
354
338
|
role?: string;
|
|
355
339
|
},
|
|
356
340
|
): string[] {
|
package/src/core/plan/types.ts
CHANGED
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import z from "zod";
|
|
6
6
|
import type { Change } from "../change.types.ts";
|
|
7
|
-
import type {
|
|
8
|
-
import type { ChangeFilter } from "../integrations/filter/filter.types.ts";
|
|
9
|
-
import type { SerializeDSL } from "../integrations/serialize/dsl.ts";
|
|
10
|
-
import type { ChangeSerializer } from "../integrations/serialize/serialize.types.ts";
|
|
7
|
+
import type { Integration } from "../integrations/integration.types.ts";
|
|
11
8
|
|
|
12
9
|
// ============================================================================
|
|
13
10
|
// Core Types
|
|
@@ -157,9 +154,9 @@ export type Plan = z.infer<typeof PlanSchema>;
|
|
|
157
154
|
*/
|
|
158
155
|
export interface CreatePlanOptions {
|
|
159
156
|
/** Filter - either FilterDSL (stored in plan) or ChangeFilter function (not stored) */
|
|
160
|
-
filter?:
|
|
157
|
+
filter?: Integration["filter"];
|
|
161
158
|
/** Serialize - either SerializeDSL (stored in plan) or ChangeSerializer function (not stored) */
|
|
162
|
-
serialize?:
|
|
159
|
+
serialize?: Integration["serialize"];
|
|
163
160
|
/** Role to use when executing the migration (SET ROLE will be added to statements) */
|
|
164
161
|
role?: string;
|
|
165
162
|
/**
|