Skip to content

Commit 9a2ca3b

Browse files
feat: handle PgLiteral in index expressions (#1561)
Co-authored-by: Shinigami <chrissi92@hotmail.de>
1 parent b4c0569 commit 9a2ca3b

23 files changed

+954
-36
lines changed

src/operations/generalTypes.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,32 @@ export type Value =
2424

2525
export type Type = string | { type: string };
2626

27-
export type Name = string | { schema?: string; name: string };
27+
export type Name = string | { schema?: string; name: string } | PgLiteralValue;
28+
29+
/**
30+
* Type guard for the object form of {@link Name}.
31+
*
32+
* Note: This only checks shape (presence of a "name" property). It intentionally
33+
* does not validate contents.
34+
*/
35+
export function isNameObject(
36+
val: unknown
37+
): val is Exclude<Name, string | PgLiteralValue> {
38+
return typeof val === 'object' && val !== null && 'name' in val;
39+
}
40+
41+
/**
42+
* Type guard for schema-qualified {@link Name} objects.
43+
*/
44+
export function isSchemaNameObject(
45+
val: unknown
46+
): val is { schema: string; name: string } {
47+
return (
48+
isNameObject(val) &&
49+
'schema' in val &&
50+
typeof (val as { schema?: unknown }).schema === 'string'
51+
);
52+
}
2853

2954
export interface IfNotExistsOption {
3055
ifNotExists?: boolean;

src/operations/indexes/createIndex.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { MigrationOptions } from '../../migrationOptions';
22
import { toArray } from '../../utils';
3-
import type { IfNotExistsOption, Name, Reversible } from '../generalTypes';
3+
import {
4+
isNameObject,
5+
type IfNotExistsOption,
6+
type Name,
7+
type Reversible,
8+
} from '../generalTypes';
49
import type { DropIndexOptions } from './dropIndex';
510
import { dropIndex } from './dropIndex';
611
import type { IndexColumn } from './shared';
@@ -63,7 +68,7 @@ export function createIndex(mOptions: MigrationOptions): CreateIndex {
6368
const columns = toArray(rawColumns);
6469

6570
const indexName = generateIndexName(
66-
typeof tableName === 'object' ? tableName.name : tableName,
71+
isNameObject(tableName) ? tableName.name : tableName,
6772
columns,
6873
options,
6974
mOptions.schemalize

src/operations/indexes/shared.ts

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { MigrationOptions } from '../../migrationOptions';
2+
import { isPgLiteral } from '../../utils';
23
import type { Literal } from '../../utils/createTransformer';
3-
import type { Name } from '../generalTypes';
4+
import { isNameObject, isSchemaNameObject, type Name } from '../generalTypes';
45
import type { CreateIndexOptions } from './createIndex';
56
import type { DropIndexOptions } from './dropIndex';
67

@@ -12,24 +13,44 @@ export interface IndexColumn {
1213
sort?: 'ASC' | 'DESC';
1314
}
1415

16+
function isIndexColumn(value: unknown): value is IndexColumn {
17+
return (
18+
typeof value === 'object' &&
19+
value !== null &&
20+
'name' in value &&
21+
typeof (value as { name?: unknown }).name === 'string'
22+
);
23+
}
24+
1525
export function generateIndexName(
1626
table: Name,
1727
columns: Array<string | IndexColumn>,
1828
options: CreateIndexOptions | DropIndexOptions,
1929
schemalize: Literal
2030
): Name {
2131
if (options.name) {
22-
return typeof table === 'object'
32+
return isSchemaNameObject(table)
2333
? { schema: table.schema, name: options.name }
2434
: options.name;
2535
}
2636

2737
const cols = columns
28-
.map((col) => schemalize(typeof col === 'string' ? col : col.name))
38+
.map((col, idx) => {
39+
if (isIndexColumn(col)) return schemalize(col.name);
40+
41+
if (isPgLiteral(col)) {
42+
const literalValue = 'value' in col ? col.value : String(col);
43+
throw new Error(
44+
`Index name must be provided when using PgLiteral columns (column #${idx + 1}: ${literalValue})`
45+
);
46+
}
47+
48+
return schemalize(col);
49+
})
2950
.join('_');
3051
const uniq = 'unique' in options && options.unique ? '_unique' : '';
3152

32-
return typeof table === 'object'
53+
return isNameObject(table)
3354
? {
3455
schema: table.schema,
3556
name: `${table.name}_${cols}${uniq}_index`,
@@ -41,29 +62,39 @@ export function generateColumnString(
4162
column: Name,
4263
mOptions: MigrationOptions
4364
): string {
65+
if (isPgLiteral(column)) {
66+
return column.toString();
67+
}
68+
4469
const name = mOptions.schemalize(column);
45-
const isSpecial = /[ ().]/.test(name);
70+
const isExpression = /[^\w".]/.test(name);
71+
if (!isExpression) {
72+
return mOptions.literal(name);
73+
}
4674

47-
return isSpecial
48-
? name // expression
49-
: mOptions.literal(name); // single column
75+
// Expressions need parentheses in index definitions, unless they're already
76+
// wrapped (we consider any expression ending with ')' as already wrapped).
77+
const alreadyWrapped = /\)$/.test(name);
78+
return alreadyWrapped ? name : `(${name})`;
5079
}
5180

5281
export function generateColumnsString(
5382
columns: Array<string | IndexColumn>,
5483
mOptions: MigrationOptions
5584
): string {
5685
return columns
57-
.map((column) =>
58-
typeof column === 'string'
59-
? generateColumnString(column, mOptions)
60-
: [
61-
generateColumnString(column.name, mOptions),
62-
column.opclass ? mOptions.literal(column.opclass) : undefined,
63-
column.sort,
64-
]
65-
.filter((s) => typeof s === 'string' && s !== '')
66-
.join(' ')
67-
)
86+
.map((column) => {
87+
if (typeof column === 'string' || isPgLiteral(column)) {
88+
return generateColumnString(column as unknown as Name, mOptions);
89+
}
90+
91+
return [
92+
generateColumnString(column.name, mOptions),
93+
column.opclass ? mOptions.literal(column.opclass) : undefined,
94+
column.sort,
95+
]
96+
.filter((s) => typeof s === 'string' && s !== '')
97+
.join(' ');
98+
})
6899
.join(', ');
69100
}

src/operations/tables/shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { applyType, escapeValue, makeComment, toArray } from '../../utils';
33
import type { Literal } from '../../utils/createTransformer';
44
import type { FunctionParamType } from '../functions';
55
import type { IfNotExistsOption, Name, Value } from '../generalTypes';
6+
import { isNameObject } from '../generalTypes';
67
import { parseSequenceOptions, type SequenceOptions } from '../sequences';
78

89
export type Action =
@@ -330,7 +331,7 @@ export function parseConstraints(
330331
comment,
331332
}: ConstraintOptions = options;
332333

333-
const tableName = typeof table === 'object' ? table.name : table;
334+
const tableName = isNameObject(table) ? table.name : table;
334335

335336
let constraints: string[] = [];
336337
const comments: string[] = [];

src/utils/createSchemalize.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,53 @@
11
import type { Name } from '../operations/generalTypes';
22
import { decamelize } from './decamelize';
33
import { identity } from './identity';
4+
import { isPgLiteral } from './PgLiteral';
45
import { quote } from './quote';
56

67
export interface SchemalizeOptions {
78
readonly shouldDecamelize: boolean;
89
readonly shouldQuote: boolean;
910
}
1011

12+
// Detect raw SQL expressions (used by indexes, constraints, etc.) to decide when to wrap in parentheses.
13+
//
14+
// Important: this is not a SQL sanitizer. It’s only a heuristic so that expression-based index columns like
15+
// meta->>'type'
16+
// meta#>>'{a,b}'
17+
// lower(email)
18+
// score * 10
19+
// are treated as expressions instead of quoted identifiers.
20+
//
21+
// JSON/JSONB operators reference:
22+
// https://www.postgresql.org/docs/current/functions-json.html
23+
//
24+
// Notes:
25+
// - We include single-char operators like '?' and '-' but only treat them as expression operators when
26+
// there is at least one alphanumeric character in the string (handled by the final logical check).
27+
const OPERATOR_PATTERN = /(->>|->|#>>|#>|@\?|@@|@>|<@|\?\||\?&|\?|#-|\|\||::)/;
28+
const LOGICAL_PATTERN = /[=<>!]+/;
29+
const FUNCTION_CALL_PATTERN = /^[\w.]+\(/;
30+
const ARITHMETIC_PATTERN = /\s+[+*/-]\s+/;
31+
const ALPHANUMERIC_PATTERN = /[a-zA-Z0-9]/;
32+
33+
function isExpression(value: string): boolean {
34+
if (OPERATOR_PATTERN.test(value)) {
35+
return true;
36+
}
37+
38+
if (FUNCTION_CALL_PATTERN.test(value)) {
39+
return true;
40+
}
41+
42+
if (ARITHMETIC_PATTERN.test(value)) {
43+
return true;
44+
}
45+
46+
// Ensure we only treat strings with logical operators as expressions when they also contain alphanumeric characters.
47+
// This avoids false positives from standalone operators like ">" or "!=".
48+
return LOGICAL_PATTERN.test(value) && ALPHANUMERIC_PATTERN.test(value);
49+
}
50+
1151
export function createSchemalize(
1252
options: SchemalizeOptions
1353
): (value: Name) => string {
@@ -18,12 +58,29 @@ export function createSchemalize(
1858
shouldQuote ? quote : identity,
1959
].reduce((acc, fn) => (fn === identity ? acc : (str) => acc(fn(str))));
2060

21-
return (value) => {
22-
if (typeof value === 'object') {
61+
return (value: Name) => {
62+
if (isPgLiteral(value)) {
63+
return value.toString();
64+
}
65+
66+
if (typeof value === 'object' && value !== null) {
2367
const { schema, name } = value;
24-
return (schema ? `${transform(schema)}.` : '') + transform(name);
68+
69+
if (name !== undefined) {
70+
return (schema ? `${transform(schema)}.` : '') + transform(name);
71+
}
72+
}
73+
74+
// Wrap raw SQL expressions in parentheses only when not quoting;
75+
// quoted values are treated as identifiers/literals and must stay unchanged.
76+
if (!shouldQuote && typeof value === 'string' && isExpression(value)) {
77+
return `(${value})`;
78+
}
79+
80+
if (typeof value === 'string') {
81+
return transform(value);
2582
}
2683

27-
return transform(value);
84+
return transform(String(value));
2885
};
2986
}

src/utils/createTransformer.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { escapeValue } from '.';
2-
import type { Name, Value } from '../operations/generalTypes';
2+
import {
3+
isNameObject,
4+
type Name,
5+
type Value,
6+
} from '../operations/generalTypes';
37

48
export type Literal = (v: Name) => string;
59

@@ -13,8 +17,7 @@ export function createTransformer(
1317
new RegExp(`{${param}}`, 'g'),
1418
val === undefined
1519
? ''
16-
: typeof val === 'string' ||
17-
(typeof val === 'object' && val !== null && 'name' in val)
20+
: typeof val === 'string' || isNameObject(val)
1821
? literal(val)
1922
: String(escapeValue(val)).replace(/\$/g, '$$$$')
2023
);

src/utils/quote.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
/**
2+
* Quote an identifier for PostgreSQL.
3+
*
4+
* Wraps the given string in double quotes and escapes any embedded double
5+
* quotes by doubling them, per PostgreSQL identifier rules.
6+
*
7+
* @param str - identifier to quote
8+
* @returns quoted identifier safe for use as a PostgreSQL identifier
9+
*/
110
export function quote(str: string): string {
2-
return `"${str}"`;
11+
return `"${str.replace(/"/g, '""')}"`;
312
}

test/integration/__snapshots__/db-config-it.spec.ts.snap.d/migrations-down.pg-13.stdout.log

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
> Migrating files:
2+
> - 096_expression_index
23
> - 095_index_nulls
34
> - 094_unlogged_table
45
> - 093_alter_column_expression
@@ -94,6 +95,25 @@
9495
> - 003_promise
9596
> - 002_callback
9697
> - 001_noop
98+
### MIGRATION 096_expression_index (DOWN) ###
99+
DROP INDEX "pgm_users_meta_id_json_operator_idx";
100+
DROP INDEX "pgm_users_meta_id_text_operator_idx";
101+
DROP INDEX "pgm_users_meta_path_json_operator_idx";
102+
DROP INDEX "pgm_users_meta_path_text_operator_idx";
103+
DROP INDEX "pgm_users_meta_contains_operator_idx";
104+
DROP INDEX "pgm_users_meta_contained_operator_idx";
105+
DROP INDEX "pgm_users_meta_has_key_operator_idx";
106+
DROP INDEX "pgm_users_meta_has_any_operator_idx";
107+
DROP INDEX "pgm_users_meta_has_all_operator_idx";
108+
DROP INDEX "pgm_users_meta_concat_operator_idx";
109+
DROP INDEX "pgm_users_meta_remove_key_operator_idx";
110+
DROP INDEX "pgm_users_meta_remove_path_operator_idx";
111+
DROP INDEX "pgm_users_meta_path_exists_operator_idx";
112+
DROP INDEX "pgm_users_meta_path_predicate_operator_idx";
113+
DROP TABLE "pgm_users";
114+
DELETE FROM "public"."pgmigrations" WHERE name='096_expression_index';
115+
116+
97117
### MIGRATION 095_index_nulls (DOWN) ###
98118
DELETE FROM "public"."pgmigrations" WHERE name='095_index_nulls';
99119

test/integration/__snapshots__/db-config-it.spec.ts.snap.d/migrations-down.pg-14.stdout.log

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
> Migrating files:
2+
> - 096_expression_index
23
> - 095_index_nulls
34
> - 094_unlogged_table
45
> - 093_alter_column_expression
@@ -94,6 +95,25 @@
9495
> - 003_promise
9596
> - 002_callback
9697
> - 001_noop
98+
### MIGRATION 096_expression_index (DOWN) ###
99+
DROP INDEX "pgm_users_meta_id_json_operator_idx";
100+
DROP INDEX "pgm_users_meta_id_text_operator_idx";
101+
DROP INDEX "pgm_users_meta_path_json_operator_idx";
102+
DROP INDEX "pgm_users_meta_path_text_operator_idx";
103+
DROP INDEX "pgm_users_meta_contains_operator_idx";
104+
DROP INDEX "pgm_users_meta_contained_operator_idx";
105+
DROP INDEX "pgm_users_meta_has_key_operator_idx";
106+
DROP INDEX "pgm_users_meta_has_any_operator_idx";
107+
DROP INDEX "pgm_users_meta_has_all_operator_idx";
108+
DROP INDEX "pgm_users_meta_concat_operator_idx";
109+
DROP INDEX "pgm_users_meta_remove_key_operator_idx";
110+
DROP INDEX "pgm_users_meta_remove_path_operator_idx";
111+
DROP INDEX "pgm_users_meta_path_exists_operator_idx";
112+
DROP INDEX "pgm_users_meta_path_predicate_operator_idx";
113+
DROP TABLE "pgm_users";
114+
DELETE FROM "public"."pgmigrations" WHERE name='096_expression_index';
115+
116+
97117
### MIGRATION 095_index_nulls (DOWN) ###
98118
DELETE FROM "public"."pgmigrations" WHERE name='095_index_nulls';
99119

0 commit comments

Comments
 (0)