fix(postgres): keep index operator classes in read and generation
Closes #7166
This commit is contained in:
31 files changed
+854
-51
No files matched your search
@@ -41,6 +41,11 @@ const structureCapabilities = computed(() => getTableStructureCapabilities(props
|
|||||||
const validationErrors = ref<string[]>([]);
|
const validationErrors = ref<string[]>([]);
|
||||||
const sqlText = ref("");
|
const sqlText = ref("");
|
||||||
const warnings = ref<string[]>([]);
|
const warnings = ref<string[]>([]);
|
||||||
|
// 仅 GIN 缺 opclass 警告阻断同步;其余降级类警告(如 “… not supported … ignored”)保持可继续。
|
||||||
|
// Match the warning's stable semantic core ("has no operator class specified")
|
||||||
|
// rather than its prefix, so rewording the leading "GIN index …" doesn't
|
||||||
|
// silently disable the gate.
|
||||||
|
const hasBlockingWarning = computed(() => warnings.value.some((w) => w.includes("has no operator class specified")));
|
||||||
const building = ref(false);
|
const building = ref(false);
|
||||||
const executing = ref(false);
|
const executing = ref(false);
|
||||||
const execError = ref("");
|
const execError = ref("");
|
||||||
@@ -122,7 +127,7 @@ async function copySql() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function execute() {
|
async function execute() {
|
||||||
if (!sqlText.value || validationErrors.value.length) return;
|
if (!sqlText.value || validationErrors.value.length || hasBlockingWarning.value) return;
|
||||||
executing.value = true;
|
executing.value = true;
|
||||||
execError.value = "";
|
execError.value = "";
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +195,7 @@ function syncTableLabel(table: DiagramTable): string {
|
|||||||
<DialogFooter class="gap-2 sm:gap-2">
|
<DialogFooter class="gap-2 sm:gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" :disabled="!sqlText" @click="copySql">{{ t("diagram.copySql") }}</Button>
|
<Button type="button" variant="outline" size="sm" :disabled="!sqlText" @click="copySql">{{ t("diagram.copySql") }}</Button>
|
||||||
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
|
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
|
||||||
<Button type="button" size="sm" :disabled="!sqlText || !!validationErrors.length || executing || building" @click="execute">
|
<Button type="button" size="sm" :disabled="!sqlText || !!validationErrors.length || hasBlockingWarning || executing || building" @click="execute">
|
||||||
{{ executing ? t("diagram.syncing") : t("diagram.executeSync") }}
|
{{ executing ? t("diagram.syncing") : t("diagram.executeSync") }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -3173,6 +3173,7 @@ function addIndex() {
|
|||||||
includedColumns: [],
|
includedColumns: [],
|
||||||
comment: "",
|
comment: "",
|
||||||
concurrently: false,
|
concurrently: false,
|
||||||
|
columnOpclasses: [],
|
||||||
markedForDrop: false,
|
markedForDrop: false,
|
||||||
});
|
});
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function createDraftIndex(tableName: string, columns: string[], existingI
|
|||||||
includedColumns: [],
|
includedColumns: [],
|
||||||
comment: "",
|
comment: "",
|
||||||
concurrently: false,
|
concurrently: false,
|
||||||
|
columnOpclasses: [],
|
||||||
markedForDrop: false,
|
markedForDrop: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface EditableStructureIndex {
|
|||||||
comment: string;
|
comment: string;
|
||||||
/** Build the index with PostgreSQL `CREATE INDEX CONCURRENTLY` (PostgreSQL only, default off). */
|
/** Build the index with PostgreSQL `CREATE INDEX CONCURRENTLY` (PostgreSQL only, default off). */
|
||||||
concurrently?: boolean;
|
concurrently?: boolean;
|
||||||
|
/** Parallel to `columns`: operator class for each key column (PostgreSQL). `null` means default. */
|
||||||
|
columnOpclasses?: (string | null)[];
|
||||||
original?: IndexInfo;
|
original?: IndexInfo;
|
||||||
markedForDrop: boolean;
|
markedForDrop: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1029,6 +1029,7 @@ export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[
|
|||||||
indexType: normalizeStructureIndexType(index.index_type),
|
indexType: normalizeStructureIndexType(index.index_type),
|
||||||
includedColumns: index.included_columns ? [...index.included_columns] : [],
|
includedColumns: index.included_columns ? [...index.included_columns] : [],
|
||||||
comment: index.comment ?? "",
|
comment: index.comment ?? "",
|
||||||
|
columnOpclasses: index.column_opclasses ? [...index.column_opclasses] : [],
|
||||||
original: index,
|
original: index,
|
||||||
markedForDrop: false,
|
markedForDrop: false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -552,6 +552,8 @@ export interface IndexInfo {
|
|||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** Parallel to `columns`: true at index i means columns[i] is a raw expression, not a plain column name. */
|
/** Parallel to `columns`: true at index i means columns[i] is a raw expression, not a plain column name. */
|
||||||
key_is_expression?: boolean[] | null;
|
key_is_expression?: boolean[] | null;
|
||||||
|
/** Parallel to `columns`: operator class name for each key column (PostgreSQL), if non-default. */
|
||||||
|
column_opclasses?: (string | null)[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferenceKeyInfo {
|
export interface ReferenceKeyInfo {
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ fn clickhouse_index_from_skipping_row(row: &[serde_json::Value]) -> IndexInfo {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,6 +793,7 @@ pub async fn list_indexes(client: &ChClient, database: &str, table: &str) -> Res
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ pub async fn list_indexes(client: &CloudflareD1Client, _schema: &str, table: &st
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(indexes)
|
Ok(indexes)
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ fn index_info_from_row(row: &mysql_async::Row) -> IndexInfo {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: (!comment_parts.is_empty()).then(|| comment_parts.join(", ")),
|
comment: (!comment_parts.is_empty()).then(|| comment_parts.join(", ")),
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -904,6 +904,7 @@ fn index_info_from_model(model: IndexModel) -> IndexInfo {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3637,6 +3638,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(spec.name, "email_1");
|
assert_eq!(spec.name, "email_1");
|
||||||
@@ -3662,6 +3664,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3905,6 +3908,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
},
|
},
|
||||||
IndexInfo {
|
IndexInfo {
|
||||||
name: "users_email_unique".to_string(),
|
name: "users_email_unique".to_string(),
|
||||||
@@ -3916,6 +3920,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
},
|
},
|
||||||
IndexInfo {
|
IndexInfo {
|
||||||
name: "users_status_idx".to_string(),
|
name: "users_status_idx".to_string(),
|
||||||
@@ -3927,6 +3932,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let after = vec![before[0].clone(), before[2].clone()];
|
let after = vec![before[0].clone(), before[2].clone()];
|
||||||
|
|||||||
@@ -5775,6 +5775,7 @@ pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Resu
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: get_opt_str(&row, "INDEX_COMMENT").filter(|value| !value.is_empty()),
|
comment: get_opt_str(&row, "INDEX_COMMENT").filter(|value| !value.is_empty()),
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
index_position
|
index_position
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ fn table_key_index(name: &str, line: &str, is_unique: bool, is_primary: bool, in
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +346,7 @@ fn secondary_index(line: &str) -> Option<IndexInfo> {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: mysql_quoted_string_argument(after_name, "COMMENT"),
|
comment: mysql_quoted_string_argument(after_name, "COMMENT"),
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ pub async fn list_indexes(pool: &mysql_async::Pool, schema: &str, table: &str) -
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -3824,23 +3824,30 @@ async fn list_indexes_for_relations_with_sql(
|
|||||||
for row in &rows {
|
for row in &rows {
|
||||||
let Ok(relid) = row.try_get::<_, i64>(0) else { continue };
|
let Ok(relid) = row.try_get::<_, i64>(0) else { continue };
|
||||||
let all_cols: Vec<String> = row.try_get::<_, Vec<String>>(2).unwrap_or_default();
|
let all_cols: Vec<String> = row.try_get::<_, Vec<String>>(2).unwrap_or_default();
|
||||||
let nkeyatts = row.try_get::<_, Option<i16>>(7).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
|
let all_opclasses: Vec<Option<String>> = row.try_get::<_, Vec<Option<String>>>(3).unwrap_or_default();
|
||||||
|
let nkeyatts = row.try_get::<_, Option<i16>>(8).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
|
||||||
let split_at = nkeyatts.min(all_cols.len());
|
let split_at = nkeyatts.min(all_cols.len());
|
||||||
let key_cols = all_cols[..split_at].to_vec();
|
let key_cols = all_cols[..split_at].to_vec();
|
||||||
|
let key_opclasses = if all_opclasses.len() == all_cols.len() {
|
||||||
|
all_opclasses[..split_at].to_vec()
|
||||||
|
} else {
|
||||||
|
vec![None; split_at]
|
||||||
|
};
|
||||||
let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] };
|
let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] };
|
||||||
let all_is_expr: Vec<bool> = row.try_get::<_, Vec<bool>>(10).unwrap_or_default();
|
let all_is_expr: Vec<bool> = row.try_get::<_, Vec<bool>>(11).unwrap_or_default();
|
||||||
let key_is_expression =
|
let key_is_expression =
|
||||||
if all_is_expr.len() == all_cols.len() { all_is_expr[..split_at].to_vec() } else { Vec::new() };
|
if all_is_expr.len() == all_cols.len() { all_is_expr[..split_at].to_vec() } else { Vec::new() };
|
||||||
result.entry(relid).or_default().push(IndexInfo {
|
result.entry(relid).or_default().push(IndexInfo {
|
||||||
name: pg_row_try_string(row, 1),
|
name: pg_row_try_string(row, 1),
|
||||||
columns: key_cols,
|
columns: key_cols,
|
||||||
is_unique: pg_row_try_bool(row, 3).unwrap_or(false),
|
is_unique: pg_row_try_bool(row, 4).unwrap_or(false),
|
||||||
is_primary: pg_row_try_bool(row, 4).unwrap_or(false),
|
is_primary: pg_row_try_bool(row, 5).unwrap_or(false),
|
||||||
filter: row.try_get::<_, Option<String>>(5).ok().flatten(),
|
filter: row.try_get::<_, Option<String>>(6).ok().flatten(),
|
||||||
index_type: row.try_get::<_, Option<String>>(6).ok().flatten(),
|
index_type: row.try_get::<_, Option<String>>(7).ok().flatten(),
|
||||||
included_columns: if included.is_empty() { None } else { Some(included) },
|
included_columns: if included.is_empty() { None } else { Some(included) },
|
||||||
comment: row.try_get::<_, Option<String>>(9).ok().flatten(),
|
comment: row.try_get::<_, Option<String>>(10).ok().flatten(),
|
||||||
key_is_expression,
|
key_is_expression,
|
||||||
|
column_opclasses: key_opclasses,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -3857,7 +3864,8 @@ fn postgres_indexes_for_relations_query_tiers() -> [&'static str; 2] {
|
|||||||
// line up, and result columns are read positionally.
|
// line up, and result columns are read positionally.
|
||||||
fn postgres_indexes_for_relations_sql() -> &'static str {
|
fn postgres_indexes_for_relations_sql() -> &'static str {
|
||||||
"SELECT t.oid::bigint AS relid, i.relname AS index_name, \
|
"SELECT t.oid::bigint AS relid, i.relname AS index_name, \
|
||||||
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
|
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, false)) ORDER BY k.n) AS columns, \
|
||||||
|
array_agg(CASE WHEN oc.opcdefault THEN NULL ELSE quote_ident(opcns.nspname) || '.' || quote_ident(oc.opcname) END ORDER BY k.n) AS column_opclasses, \
|
||||||
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
||||||
ix.indisprimary AS is_primary, \
|
ix.indisprimary AS is_primary, \
|
||||||
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
||||||
@@ -3870,8 +3878,10 @@ fn postgres_indexes_for_relations_sql() -> &'static str {
|
|||||||
JOIN pg_class t ON t.oid = ix.indrelid \
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||||
JOIN pg_class i ON i.oid = ix.indexrelid \
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||||
JOIN pg_am am ON am.oid = i.relam \
|
JOIN pg_am am ON am.oid = i.relam \
|
||||||
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
|
JOIN LATERAL unnest(ix.indkey, ix.indclass) WITH ORDINALITY AS k(attnum, class_oid, n) ON true \
|
||||||
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
|
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
|
||||||
|
LEFT JOIN pg_opclass oc ON oc.oid = k.class_oid \
|
||||||
|
LEFT JOIN pg_namespace opcns ON opcns.oid = oc.opcnamespace \
|
||||||
WHERE t.oid = ANY($1::bigint[]) \
|
WHERE t.oid = ANY($1::bigint[]) \
|
||||||
GROUP BY t.oid, i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
|
GROUP BY t.oid, i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
|
||||||
ORDER BY t.oid, i.relname"
|
ORDER BY t.oid, i.relname"
|
||||||
@@ -3882,7 +3892,7 @@ fn postgres_indexes_for_relations_sql() -> &'static str {
|
|||||||
fn postgres_indexes_for_relations_compat_sql() -> &'static str {
|
fn postgres_indexes_for_relations_compat_sql() -> &'static str {
|
||||||
"SELECT t.oid::bigint AS relid, i.relname AS index_name, \
|
"SELECT t.oid::bigint AS relid, i.relname AS index_name, \
|
||||||
ARRAY( \
|
ARRAY( \
|
||||||
SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, true)) \
|
SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, false)) \
|
||||||
FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \
|
FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \
|
||||||
LEFT JOIN pg_attribute a \
|
LEFT JOIN pg_attribute a \
|
||||||
ON a.attrelid = t.oid \
|
ON a.attrelid = t.oid \
|
||||||
@@ -3890,6 +3900,19 @@ fn postgres_indexes_for_relations_compat_sql() -> &'static str {
|
|||||||
AND a.attnum > 0 \
|
AND a.attnum > 0 \
|
||||||
ORDER BY pos.n \
|
ORDER BY pos.n \
|
||||||
) AS columns, \
|
) AS columns, \
|
||||||
|
ARRAY( \
|
||||||
|
SELECT CASE WHEN oc.opcdefault THEN NULL \
|
||||||
|
ELSE oc.opcname \
|
||||||
|
END \
|
||||||
|
FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \
|
||||||
|
LEFT JOIN pg_attribute a \
|
||||||
|
ON a.attrelid = t.oid \
|
||||||
|
AND a.attnum = (string_to_array(ix.indkey::text, ' '))[pos.n]::int2 \
|
||||||
|
AND a.attnum > 0 \
|
||||||
|
LEFT JOIN pg_opclass oc \
|
||||||
|
ON oc.oid = (string_to_array(ix.indclass::text, ' '))[pos.n]::oid \
|
||||||
|
ORDER BY pos.n \
|
||||||
|
) AS column_opclasses, \
|
||||||
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
||||||
ix.indisprimary AS is_primary, \
|
ix.indisprimary AS is_primary, \
|
||||||
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
||||||
@@ -7335,7 +7358,8 @@ async fn execute_query_with_max_rows_inner(
|
|||||||
// Sibling of `postgres_indexes_for_relations_sql` (~line 3288), for a single
|
// Sibling of `postgres_indexes_for_relations_sql` (~line 3288), for a single
|
||||||
// (schema, table) instead of a batch of oids — see the note there.
|
// (schema, table) instead of a batch of oids — see the note there.
|
||||||
const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
|
const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
|
||||||
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
|
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, false)) ORDER BY k.n) AS columns, \
|
||||||
|
array_agg(CASE WHEN oc.opcdefault THEN NULL ELSE quote_ident(opcns.nspname) || '.' || quote_ident(oc.opcname) END ORDER BY k.n) AS column_opclasses, \
|
||||||
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
||||||
ix.indisprimary AS is_primary, \
|
ix.indisprimary AS is_primary, \
|
||||||
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
||||||
@@ -7349,8 +7373,10 @@ const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
|
|||||||
JOIN pg_class i ON i.oid = ix.indexrelid \
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||||
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||||
JOIN pg_am am ON am.oid = i.relam \
|
JOIN pg_am am ON am.oid = i.relam \
|
||||||
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
|
JOIN LATERAL unnest(ix.indkey, ix.indclass) WITH ORDINALITY AS k(attnum, class_oid, n) ON true \
|
||||||
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
|
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
|
||||||
|
LEFT JOIN pg_opclass oc ON oc.oid = k.class_oid \
|
||||||
|
LEFT JOIN pg_namespace opcns ON opcns.oid = oc.opcnamespace \
|
||||||
WHERE t.oid = (CASE WHEN $1 = '' THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END)::regclass \
|
WHERE t.oid = (CASE WHEN $1 = '' THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END)::regclass \
|
||||||
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
|
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
|
||||||
ORDER BY i.relname";
|
ORDER BY i.relname";
|
||||||
@@ -7359,7 +7385,7 @@ const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
|
|||||||
// 3312) — see the note on `POSTGRES_INDEXES_SQL` above.
|
// 3312) — see the note on `POSTGRES_INDEXES_SQL` above.
|
||||||
const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \
|
const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \
|
||||||
ARRAY( \
|
ARRAY( \
|
||||||
SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, true)) \
|
SELECT COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, pos.n, false)) \
|
||||||
FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \
|
FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \
|
||||||
LEFT JOIN pg_attribute a \
|
LEFT JOIN pg_attribute a \
|
||||||
ON a.attrelid = t.oid \
|
ON a.attrelid = t.oid \
|
||||||
@@ -7367,6 +7393,19 @@ const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \
|
|||||||
AND a.attnum > 0 \
|
AND a.attnum > 0 \
|
||||||
ORDER BY pos.n \
|
ORDER BY pos.n \
|
||||||
) AS columns, \
|
) AS columns, \
|
||||||
|
ARRAY( \
|
||||||
|
SELECT CASE WHEN oc.opcdefault THEN NULL \
|
||||||
|
ELSE oc.opcname \
|
||||||
|
END \
|
||||||
|
FROM generate_series(1, array_length(string_to_array(ix.indkey::text, ' '), 1)) AS pos(n) \
|
||||||
|
LEFT JOIN pg_attribute a \
|
||||||
|
ON a.attrelid = t.oid \
|
||||||
|
AND a.attnum = (string_to_array(ix.indkey::text, ' '))[pos.n]::int2 \
|
||||||
|
AND a.attnum > 0 \
|
||||||
|
LEFT JOIN pg_opclass oc \
|
||||||
|
ON oc.oid = (string_to_array(ix.indclass::text, ' '))[pos.n]::oid \
|
||||||
|
ORDER BY pos.n \
|
||||||
|
) AS column_opclasses, \
|
||||||
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
(ix.indisunique AND ix.indisvalid) AS is_unique, \
|
||||||
ix.indisprimary AS is_primary, \
|
ix.indisprimary AS is_primary, \
|
||||||
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
||||||
@@ -7465,6 +7504,16 @@ pub(crate) async fn postgres_relation_relkind(
|
|||||||
row.map(|row| row.try_get::<_, String>(0)).transpose().map_err(pg_error_to_string)
|
row.map(|row| row.try_get::<_, String>(0)).transpose().map_err(pg_error_to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute per-column operator classes from `pg_opclass` joined via
|
||||||
|
/// `pg_index.indclass`. For each key column position, the schema-qualified
|
||||||
|
/// opclass (`quote_ident(nspname) || '.' || quote_ident(opcname)`) is returned
|
||||||
|
/// unless the class is the type's default (`oc.opcdefault`), so DDL regeneration
|
||||||
|
/// resolves the opclass regardless of `search_path`. This applies to every key
|
||||||
|
/// position, including expression keys (`a.attname IS NULL`): the per-column
|
||||||
|
/// `pg_get_indexdef(indexrelid, colno, pretty)` call returns only the bare
|
||||||
|
/// expression text (PostgreSQL sets `attrsOnly = (colno != 0)`, so the
|
||||||
|
/// opclass/COLLATE/DESC block is skipped — see `ruleutils.c`), so the opclass
|
||||||
|
/// must be read from `indclass` rather than parsed out of that text.
|
||||||
async fn list_indexes_with_sql(
|
async fn list_indexes_with_sql(
|
||||||
client: &deadpool_postgres::Client,
|
client: &deadpool_postgres::Client,
|
||||||
sql: &str,
|
sql: &str,
|
||||||
@@ -7477,25 +7526,32 @@ async fn list_indexes_with_sql(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let all_cols: Vec<String> = row.try_get::<_, Vec<String>>(1).unwrap_or_default();
|
let all_cols: Vec<String> = row.try_get::<_, Vec<String>>(1).unwrap_or_default();
|
||||||
let nkeyatts = row.try_get::<_, Option<i16>>(6).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
|
let all_opclasses: Vec<Option<String>> = row.try_get::<_, Vec<Option<String>>>(2).unwrap_or_default();
|
||||||
|
let nkeyatts = row.try_get::<_, Option<i16>>(7).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
|
||||||
let split_at = nkeyatts.min(all_cols.len());
|
let split_at = nkeyatts.min(all_cols.len());
|
||||||
let key_cols = all_cols[..split_at].to_vec();
|
let key_cols = all_cols[..split_at].to_vec();
|
||||||
|
let key_opclasses = if all_opclasses.len() == all_cols.len() {
|
||||||
|
all_opclasses[..split_at].to_vec()
|
||||||
|
} else {
|
||||||
|
vec![None; split_at]
|
||||||
|
};
|
||||||
let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] };
|
let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] };
|
||||||
// `a.attname IS NULL` at a given key position means that key part came back from
|
// `a.attname IS NULL` at a given key position means that key part came back from
|
||||||
// pg_get_indexdef (a functional/expression key part), not from a real column (#6295).
|
// pg_get_indexdef (a functional/expression key part), not from a real column (#6295).
|
||||||
let all_is_expr: Vec<bool> = row.try_get::<_, Vec<bool>>(9).unwrap_or_default();
|
let all_is_expr: Vec<bool> = row.try_get::<_, Vec<bool>>(10).unwrap_or_default();
|
||||||
let key_is_expression =
|
let key_is_expression =
|
||||||
if all_is_expr.len() == all_cols.len() { all_is_expr[..split_at].to_vec() } else { Vec::new() };
|
if all_is_expr.len() == all_cols.len() { all_is_expr[..split_at].to_vec() } else { Vec::new() };
|
||||||
IndexInfo {
|
IndexInfo {
|
||||||
name: pg_row_try_string(row, 0),
|
name: pg_row_try_string(row, 0),
|
||||||
columns: key_cols,
|
columns: key_cols,
|
||||||
is_unique: pg_row_try_bool(row, 2).unwrap_or(false),
|
is_unique: pg_row_try_bool(row, 3).unwrap_or(false),
|
||||||
is_primary: pg_row_try_bool(row, 3).unwrap_or(false),
|
is_primary: pg_row_try_bool(row, 4).unwrap_or(false),
|
||||||
filter: row.try_get::<_, Option<String>>(4).ok().flatten(),
|
filter: row.try_get::<_, Option<String>>(5).ok().flatten(),
|
||||||
index_type: row.try_get::<_, Option<String>>(5).ok().flatten(),
|
index_type: row.try_get::<_, Option<String>>(6).ok().flatten(),
|
||||||
included_columns: if included.is_empty() { None } else { Some(included) },
|
included_columns: if included.is_empty() { None } else { Some(included) },
|
||||||
comment: row.try_get::<_, Option<String>>(8).ok().flatten(),
|
comment: row.try_get::<_, Option<String>>(9).ok().flatten(),
|
||||||
key_is_expression,
|
key_is_expression,
|
||||||
|
column_opclasses: key_opclasses,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -12316,6 +12372,26 @@ mod tests {
|
|||||||
assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("AS key_is_expression"));
|
assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("AS key_is_expression"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_metadata_schema_qualifies_opclass() {
|
||||||
|
// The LATERAL (modern) index-introspection SQL returns each non-default
|
||||||
|
// opclass schema-qualified (`quote_ident(nspname) || '.' || quote_ident(opcname)`)
|
||||||
|
// and joins `pg_namespace` on `opcnamespace`, so DDL regeneration resolves
|
||||||
|
// opclasses (e.g. `gin_trgm_ops` from `pg_trgm`) regardless of `search_path`.
|
||||||
|
// The compat (pre-LATERAL) fallback keeps bare `oc.opcname` — no regression,
|
||||||
|
// just no schema-qualification for the rare old-PG × non-default-schema case.
|
||||||
|
for sql in [POSTGRES_INDEXES_SQL, postgres_indexes_for_relations_sql()] {
|
||||||
|
assert!(
|
||||||
|
sql.contains("quote_ident(opcns.nspname) || '.' || quote_ident(oc.opcname)"),
|
||||||
|
"expected schema-qualified opclass in {sql}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sql.contains("LEFT JOIN pg_namespace opcns ON opcns.oid = oc.opcnamespace"),
|
||||||
|
"expected opcnamespace join in {sql}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn postgres_owner_metadata_casts_relkind_to_text() {
|
fn postgres_owner_metadata_casts_relkind_to_text() {
|
||||||
assert!(POSTGRES_OWNERS_SQL.contains("c.relkind::text AS relkind"));
|
assert!(POSTGRES_OWNERS_SQL.contains("c.relkind::text AS relkind"));
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ pub async fn list_indexes(pool: &Pool, _schema: &str, table: &str) -> Result<Vec
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ pub async fn list_indexes(client: &RqliteClient, _schema: &str, table: &str) ->
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2493,6 +2493,7 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Ok(indexes);
|
return Ok(indexes);
|
||||||
@@ -2546,6 +2547,7 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(indexes)
|
Ok(indexes)
|
||||||
|
|||||||
@@ -2940,6 +2940,7 @@ pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &st
|
|||||||
},
|
},
|
||||||
comment: row.get::<&str, _>(7).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
|
comment: row.get::<&str, _>(7).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ pub async fn list_indexes(client: &TursoClient, _schema: &str, table: &str) -> R
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let table = doc_table("orders", vec![col("user_id", "integer")], vec![index]);
|
let table = doc_table("orders", vec![col("user_id", "integer")], vec![index]);
|
||||||
|
|
||||||
@@ -623,6 +624,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let table = doc_table("orders", vec![col("reference", "text")], vec![index]);
|
let table = doc_table("orders", vec![col("reference", "text")], vec![index]);
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
@@ -642,6 +644,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let mut id = col("id", "integer");
|
let mut id = col("id", "integer");
|
||||||
id.is_primary_key = true;
|
id.is_primary_key = true;
|
||||||
@@ -664,6 +667,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let table = doc_table("orders", vec![col("status", "text")], vec![index]);
|
let table = doc_table("orders", vec![col("status", "text")], vec![index]);
|
||||||
|
|
||||||
@@ -866,6 +870,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let out =
|
let out =
|
||||||
to_dbml(&snapshot(vec![doc_table("orders", vec![col("status", "text")], vec![index])], vec!["public"]));
|
to_dbml(&snapshot(vec![doc_table("orders", vec![col("status", "text")], vec![index])], vec!["public"]));
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3213,6 +3213,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let mut filtered = index("uq_active_code", &["active_code"], true, false);
|
let mut filtered = index("uq_active_code", &["active_code"], true, false);
|
||||||
filtered.filter = Some("active = true".to_string());
|
filtered.filter = Some("active = true".to_string());
|
||||||
@@ -8210,6 +8211,7 @@ async fn external_driver_gaussdb_m_indexes(
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: current_comment.clone(),
|
comment: current_comment.clone(),
|
||||||
key_is_expression: current_is_expression.clone(),
|
key_is_expression: current_is_expression.clone(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Start new index
|
// Start new index
|
||||||
@@ -8279,6 +8281,7 @@ async fn external_driver_gaussdb_m_indexes(
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: current_comment,
|
comment: current_comment,
|
||||||
key_is_expression: current_is_expression,
|
key_is_expression: current_is_expression,
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10266,6 +10269,35 @@ mod ddl_tests {
|
|||||||
assert!(!ddl.contains("PARTITION BY"));
|
assert!(!ddl.contains("PARTITION BY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_table_ddl_appends_opclass_to_expression_index_key() {
|
||||||
|
// The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the
|
||||||
|
// bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the opclass
|
||||||
|
// block is skipped — see `ruleutils.c`). The opclass is read separately from
|
||||||
|
// `indclass` into `column_opclasses`, so table DDL must append it to an
|
||||||
|
// expression key just like a real column.
|
||||||
|
let id = column("id", "integer");
|
||||||
|
let indexes = vec![db::IndexInfo {
|
||||||
|
name: "users_lower_email_trgm_idx".to_string(),
|
||||||
|
columns: vec!["lower(email)".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![true],
|
||||||
|
column_opclasses: vec![Some("gin_trgm_ops".to_string())],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let ddl = render_postgres_table_ddl("public", "users", &[id], &indexes, &[], None);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ddl.contains("USING gin (lower(email) gin_trgm_ops)"),
|
||||||
|
"expected expression key with appended opclass, got: {ddl}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn postgres_table_ddl_renders_partition_children_and_subpartitions() {
|
fn postgres_table_ddl_renders_partition_children_and_subpartitions() {
|
||||||
let mut id = column("id", "integer");
|
let mut id = column("id", "integer");
|
||||||
@@ -10280,6 +10312,7 @@ mod ddl_tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}];
|
}];
|
||||||
let partition_info = db::postgres::PostgresTablePartitionInfo {
|
let partition_info = db::postgres::PostgresTablePartitionInfo {
|
||||||
is_partition: true,
|
is_partition: true,
|
||||||
@@ -10330,6 +10363,7 @@ mod ddl_tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}];
|
}];
|
||||||
let partition_info = db::postgres::PostgresTablePartitionInfo {
|
let partition_info = db::postgres::PostgresTablePartitionInfo {
|
||||||
is_partition: true,
|
is_partition: true,
|
||||||
@@ -11919,7 +11953,28 @@ fn render_postgres_table_ddl_with_partition_info(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let unique = if idx.is_unique { "UNIQUE " } else { "" };
|
let unique = if idx.is_unique { "UNIQUE " } else { "" };
|
||||||
let cols = idx.columns.iter().map(|c| pg_ident(c)).collect::<Vec<_>>().join(", ");
|
let cols = idx
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| {
|
||||||
|
// A real column is quoted via `pg_ident`; an expression/functional key part
|
||||||
|
// arrives as raw expression text (the per-column `pg_get_indexdef` omits the
|
||||||
|
// opclass — see `crates/dbx-core/src/db/postgres.rs`), so quoting the whole
|
||||||
|
// thing as an identifier would turn it into a nonexistent column reference
|
||||||
|
// (#6295).
|
||||||
|
let is_expr = idx.key_is_expression.get(i).copied().unwrap_or(false);
|
||||||
|
let mut col = if is_expr { c.clone() } else { pg_ident(c) };
|
||||||
|
// The opclass is read separately from `pg_index.indclass` for every key
|
||||||
|
// position (including expression keys) and appended uniformly — it never
|
||||||
|
// lives inside the expression text, so there is no duplication risk.
|
||||||
|
if let Some(opclass) = idx.column_opclasses.get(i).and_then(|o| o.as_deref()) {
|
||||||
|
col.push_str(&format!(" {opclass}"));
|
||||||
|
}
|
||||||
|
col
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
let using = idx.index_type.as_deref().map(|t| format!(" USING {t}")).unwrap_or_default();
|
let using = idx.index_type.as_deref().map(|t| format!(" USING {t}")).unwrap_or_default();
|
||||||
let include = idx
|
let include = idx
|
||||||
.included_columns
|
.included_columns
|
||||||
|
|||||||
@@ -2866,6 +2866,14 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff
|
|||||||
if source_included.is_empty() { "none".to_string() } else { source_included.join(", ") }
|
if source_included.is_empty() { "none".to_string() } else { source_included.join(", ") }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if source_index.column_opclasses != target_index.column_opclasses {
|
||||||
|
let fmt_opclass = |o: &Option<String>| -> String {
|
||||||
|
o.as_deref().filter(|v| !v.is_empty()).unwrap_or("default").to_string()
|
||||||
|
};
|
||||||
|
let src_str = source_index.column_opclasses.iter().map(fmt_opclass).collect::<Vec<_>>().join(", ");
|
||||||
|
let tgt_str = target_index.column_opclasses.iter().map(fmt_opclass).collect::<Vec<_>>().join(", ");
|
||||||
|
changes.push(format!("opclass: {} → {}", tgt_str, src_str));
|
||||||
|
}
|
||||||
if !changes.is_empty() {
|
if !changes.is_empty() {
|
||||||
diffs.push(IndexDiff {
|
diffs.push(IndexDiff {
|
||||||
diff_type: "modified".to_string(),
|
diff_type: "modified".to_string(),
|
||||||
@@ -5645,6 +5653,7 @@ mod tests {
|
|||||||
included_columns: overrides.included_columns,
|
included_columns: overrides.included_columns,
|
||||||
comment: overrides.comment,
|
comment: overrides.comment,
|
||||||
key_is_expression: overrides.key_is_expression,
|
key_is_expression: overrides.key_is_expression,
|
||||||
|
column_opclasses: overrides.column_opclasses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6591,6 +6600,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let diff = TableDiff {
|
let diff = TableDiff {
|
||||||
diff_type: "modified".into(),
|
diff_type: "modified".into(),
|
||||||
@@ -6652,6 +6662,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let unchanged_fk = ForeignKeyInfo {
|
let unchanged_fk = ForeignKeyInfo {
|
||||||
name: "fk_events_parent".into(),
|
name: "fk_events_parent".into(),
|
||||||
@@ -6989,6 +7000,7 @@ mod tests {
|
|||||||
included_columns: Some(vec!["payload".into()]),
|
included_columns: Some(vec!["payload".into()]),
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
let btree_sql = create_index_sql("events", &btree, DatabaseType::SqlServer, Some("dbo"));
|
let btree_sql = create_index_sql("events", &btree, DatabaseType::SqlServer, Some("dbo"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -7007,6 +7019,7 @@ mod tests {
|
|||||||
included_columns: Some(vec!["status".into(), "payload".into()]),
|
included_columns: Some(vec!["status".into(), "payload".into()]),
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
create_index_sql("events", &columnstore, DatabaseType::SqlServer, Some("dbo")),
|
create_index_sql("events", &columnstore, DatabaseType::SqlServer, Some("dbo")),
|
||||||
@@ -7702,6 +7715,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_orders_status".to_string(),
|
name: "idx_orders_status".to_string(),
|
||||||
@@ -7713,6 +7727,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -7721,6 +7736,44 @@ mod tests {
|
|||||||
assert_eq!(diffs[0].changes, vec!["unique: YES → NO", "columns: status → status, created_at"]);
|
assert_eq!(diffs[0].changes, vec!["unique: YES → NO", "columns: status → status, created_at"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_opclass_change_in_postgres_index() {
|
||||||
|
let source_index = index(IndexInfo {
|
||||||
|
name: "idx_name_trgm".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![Some("gin_trgm_ops".to_string())],
|
||||||
|
});
|
||||||
|
let target_index = index(IndexInfo {
|
||||||
|
name: "idx_name_trgm".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![None],
|
||||||
|
});
|
||||||
|
|
||||||
|
let diffs = diff_indexes(&[source_index], &[target_index]);
|
||||||
|
|
||||||
|
assert_eq!(diffs.len(), 1);
|
||||||
|
assert_eq!(diffs[0].diff_type, "modified");
|
||||||
|
assert!(
|
||||||
|
diffs[0].changes.iter().any(|c| c.contains("opclass")),
|
||||||
|
"Expected opclass change line, got: {:?}",
|
||||||
|
diffs[0].changes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_mysql_functional_index_changes_and_preserves_expression_ddl() {
|
fn detects_mysql_functional_index_changes_and_preserves_expression_ddl() {
|
||||||
let functional_key_part = "((case when (`STATUS` = _utf8mb4'online') then _utf8mb4'online' else NULL end))";
|
let functional_key_part = "((case when (`STATUS` = _utf8mb4'online') then _utf8mb4'online' else NULL end))";
|
||||||
@@ -7734,6 +7787,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
let target_index = index(IndexInfo {
|
let target_index = index(IndexInfo {
|
||||||
name: "test_UNIQUE".to_string(),
|
name: "test_UNIQUE".to_string(),
|
||||||
@@ -7745,6 +7799,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]);
|
let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]);
|
||||||
@@ -7806,6 +7861,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: vec![false, false, false, true],
|
key_is_expression: vec![false, false, false, true],
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
let sql = generate_schema_sync_sql(
|
let sql = generate_schema_sync_sql(
|
||||||
@@ -7865,6 +7921,7 @@ mod tests {
|
|||||||
expression_key_part.to_string(),
|
expression_key_part.to_string(),
|
||||||
],
|
],
|
||||||
key_is_expression: vec![false, false, false, true],
|
key_is_expression: vec![false, false, false, true],
|
||||||
|
column_opclasses: vec![],
|
||||||
is_unique: true,
|
is_unique: true,
|
||||||
is_primary: false,
|
is_primary: false,
|
||||||
filter: None,
|
filter: None,
|
||||||
@@ -7931,6 +7988,7 @@ mod tests {
|
|||||||
name: "idx_weird_columns".to_string(),
|
name: "idx_weird_columns".to_string(),
|
||||||
columns: vec!["order item".to_string(), "a(b)".to_string(), "a::b".to_string()],
|
columns: vec!["order item".to_string(), "a(b)".to_string(), "a::b".to_string()],
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
is_unique: false,
|
is_unique: false,
|
||||||
is_primary: false,
|
is_primary: false,
|
||||||
filter: None,
|
filter: None,
|
||||||
@@ -8116,6 +8174,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})),
|
})),
|
||||||
target: None,
|
target: None,
|
||||||
changes: Vec::new(),
|
changes: Vec::new(),
|
||||||
@@ -8459,6 +8518,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})),
|
})),
|
||||||
target: None,
|
target: None,
|
||||||
changes: Vec::new(),
|
changes: Vec::new(),
|
||||||
@@ -9181,6 +9241,7 @@ mod tests {
|
|||||||
included_columns: Some(vec!["User ID".to_string()]),
|
included_columns: Some(vec!["User ID".to_string()]),
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
foreign_keys: vec![foreign_key(ForeignKeyInfo {
|
foreign_keys: vec![foreign_key(ForeignKeyInfo {
|
||||||
name: "Order User FK".to_string(),
|
name: "Order User FK".to_string(),
|
||||||
@@ -9244,6 +9305,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: Some("status lookup".to_string()),
|
comment: Some("status lookup".to_string()),
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
foreign_keys: vec![foreign_key(ForeignKeyInfo {
|
foreign_keys: vec![foreign_key(ForeignKeyInfo {
|
||||||
name: "user-fk".to_string(),
|
name: "user-fk".to_string(),
|
||||||
@@ -9297,6 +9359,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
foreign_keys: vec![foreign_key(ForeignKeyInfo {
|
foreign_keys: vec![foreign_key(ForeignKeyInfo {
|
||||||
name: "parent item fk".to_string(),
|
name: "parent item fk".to_string(),
|
||||||
@@ -10369,6 +10432,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})),
|
})),
|
||||||
target: None,
|
target: None,
|
||||||
changes: vec![],
|
changes: vec![],
|
||||||
@@ -10410,6 +10474,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})),
|
})),
|
||||||
changes: vec![],
|
changes: vec![],
|
||||||
}]),
|
}]),
|
||||||
@@ -10808,6 +10873,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_t".into(),
|
name: "idx_t".into(),
|
||||||
@@ -10819,6 +10885,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
assert_eq!(diffs.len(), 1, "index type diff detected");
|
assert_eq!(diffs.len(), 1, "index type diff detected");
|
||||||
@@ -10838,6 +10905,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_t".into(),
|
name: "idx_t".into(),
|
||||||
@@ -10849,6 +10917,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
assert_eq!(diffs[0].changes.iter().filter(|c| c.contains("FULLTEXT")).count(), 1, "fulltext change");
|
assert_eq!(diffs[0].changes.iter().filter(|c| c.contains("FULLTEXT")).count(), 1, "fulltext change");
|
||||||
@@ -10868,6 +10937,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_t".into(),
|
name: "idx_t".into(),
|
||||||
@@ -10879,6 +10949,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
assert_eq!(diffs.len(), 1, "order diff detected");
|
assert_eq!(diffs.len(), 1, "order diff detected");
|
||||||
@@ -10899,6 +10970,7 @@ mod tests {
|
|||||||
included_columns: Some(vec!["b".into(), "c".into()]),
|
included_columns: Some(vec!["b".into(), "c".into()]),
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_t".into(),
|
name: "idx_t".into(),
|
||||||
@@ -10910,6 +10982,7 @@ mod tests {
|
|||||||
included_columns: Some(vec!["b".into()]),
|
included_columns: Some(vec!["b".into()]),
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
assert_eq!(diffs.len(), 1, "included columns diff detected");
|
assert_eq!(diffs.len(), 1, "included columns diff detected");
|
||||||
@@ -10929,6 +11002,7 @@ mod tests {
|
|||||||
included_columns: Some(vec!["b".into()]),
|
included_columns: Some(vec!["b".into()]),
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_t".into(),
|
name: "idx_t".into(),
|
||||||
@@ -10940,6 +11014,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
assert_eq!(diffs.len(), 1, "included added");
|
assert_eq!(diffs.len(), 1, "included added");
|
||||||
@@ -10959,6 +11034,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
&[index(IndexInfo {
|
&[index(IndexInfo {
|
||||||
name: "idx_t".into(),
|
name: "idx_t".into(),
|
||||||
@@ -10970,6 +11046,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})],
|
})],
|
||||||
);
|
);
|
||||||
assert_eq!(diffs.len(), 1, "filter diff");
|
assert_eq!(diffs.len(), 1, "filter diff");
|
||||||
@@ -10991,6 +11068,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
index(IndexInfo {
|
index(IndexInfo {
|
||||||
name: "idx_modified".into(),
|
name: "idx_modified".into(),
|
||||||
@@ -11002,6 +11080,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
&[
|
&[
|
||||||
@@ -11015,6 +11094,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
index(IndexInfo {
|
index(IndexInfo {
|
||||||
name: "idx_modified".into(),
|
name: "idx_modified".into(),
|
||||||
@@ -11026,6 +11106,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -11394,6 +11475,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})),
|
})),
|
||||||
target: None,
|
target: None,
|
||||||
changes: vec![],
|
changes: vec![],
|
||||||
@@ -11412,6 +11494,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
})),
|
})),
|
||||||
changes: vec![],
|
changes: vec![],
|
||||||
},
|
},
|
||||||
@@ -11807,6 +11890,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
target: None,
|
target: None,
|
||||||
changes: vec![],
|
changes: vec![],
|
||||||
@@ -12059,6 +12143,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
target: None,
|
target: None,
|
||||||
changes: vec![],
|
changes: vec![],
|
||||||
|
|||||||
@@ -114,6 +114,45 @@ pub(super) fn has_existing_index_change(index: &EditableStructureIndex) -> bool
|
|||||||
|| index_list_changed(&index.included_columns, original.included_columns.as_ref())
|
|| index_list_changed(&index.included_columns, original.included_columns.as_ref())
|
||||||
|| clean(&index.filter) != clean(original.filter.as_deref().unwrap_or(""))
|
|| clean(&index.filter) != clean(original.filter.as_deref().unwrap_or(""))
|
||||||
|| clean(&index.comment) != clean(original.comment.as_deref().unwrap_or(""))
|
|| clean(&index.comment) != clean(original.comment.as_deref().unwrap_or(""))
|
||||||
|
|| index_opclasses_changed(
|
||||||
|
&index.column_opclasses,
|
||||||
|
&original.column_opclasses,
|
||||||
|
&index.columns,
|
||||||
|
&original.columns,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare opclass arrays positionally, skipping expression columns.
|
||||||
|
/// When the column list has changed we already detect that above; here we only
|
||||||
|
/// compare opclasses for columns that exist in both the edited and original lists.
|
||||||
|
fn index_opclasses_changed(
|
||||||
|
next: &[Option<String>],
|
||||||
|
previous: &[Option<String>],
|
||||||
|
next_cols: &[String],
|
||||||
|
prev_cols: &[String],
|
||||||
|
) -> bool {
|
||||||
|
// If the edited side has explicit opclasses, compare positionally.
|
||||||
|
if !next.is_empty() {
|
||||||
|
// Length mismatch (shouldn't happen when columns are in lockstep, but
|
||||||
|
// defend against it): any difference in length is a change.
|
||||||
|
if next.len() != previous.len() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return next.iter().zip(previous.iter()).any(|(n, p)| {
|
||||||
|
let n_flat = n.as_deref().map(clean).filter(|v| !v.is_empty());
|
||||||
|
let p_flat = p.as_deref().map(clean).filter(|v| !v.is_empty());
|
||||||
|
n_flat != p_flat
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If the edited side has no opclasses, check whether the original side
|
||||||
|
// had any non-default opclasses for columns still present.
|
||||||
|
if previous.iter().any(|o| o.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false)) {
|
||||||
|
let prev_set: std::collections::HashSet<_> = prev_cols.iter().map(|c| clean(c)).collect();
|
||||||
|
return next_cols.iter().zip(previous.iter()).any(|(nc, po)| {
|
||||||
|
po.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false) && prev_set.contains(&clean(nc))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn index_list_changed(next: &[String], previous: Option<&Vec<String>>) -> bool {
|
pub(super) fn index_list_changed(next: &[String], previous: Option<&Vec<String>>) -> bool {
|
||||||
@@ -170,14 +209,19 @@ fn mysql_index_column_sql(column: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn postgres_index_column_sql(column: &str, is_expression: bool) -> String {
|
fn postgres_index_column_sql(column: &str, is_expression: bool, opclass: Option<&str>) -> String {
|
||||||
// Expression/functional index key parts arrive as raw expression text, not a plain
|
// The base key text: a real column is quoted as an identifier; an expression/functional
|
||||||
// column name; quoting the whole expression as an identifier turns it into a literal
|
// key part arrives as raw expression text (from the per-column `pg_get_indexdef`, which
|
||||||
// column reference that doesn't exist (#6295).
|
// omits the opclass — see `list_indexes_with_sql`), so quoting the whole thing as an
|
||||||
if is_expression {
|
// identifier would turn it into a nonexistent column reference (#6295).
|
||||||
column.trim().to_string()
|
let base =
|
||||||
} else {
|
if is_expression { column.trim().to_string() } else { quote_ident(StructureDialect::Postgres, column.trim()) };
|
||||||
quote_ident(StructureDialect::Postgres, column)
|
// The opclass is read separately from `pg_index.indclass` for every key position
|
||||||
|
// (including expression keys) and appended uniformly — it never lives inside the
|
||||||
|
// expression text, so there is no duplication risk.
|
||||||
|
match opclass.filter(|o| !o.is_empty()) {
|
||||||
|
Some(opc) => format!("{} {}", base, opc),
|
||||||
|
None => base,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +263,49 @@ fn key_expression_flags(index: &EditableStructureIndex, columns: &[String]) -> V
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-key operator class provenance for `columns` (the edited/current key list).
|
||||||
|
///
|
||||||
|
/// Honors explicit per-key opclasses (`index.column_opclasses`) positionally when
|
||||||
|
/// they are aligned to `columns` (non-empty, same length) and represent a genuine
|
||||||
|
/// edit: either there is no `original` (a newly created index, where the UI
|
||||||
|
/// authors `column_opclasses` in lockstep with `columns`), or the edited
|
||||||
|
/// opclasses differ from the round-tripped `original.column_opclasses`. A stale
|
||||||
|
/// copy left behind by a column toggle/reorder — still equal to the original —
|
||||||
|
/// falls through to the order-independent text-matching below, so opclasses are
|
||||||
|
/// never misassigned positionally before the editor maintains `column_opclasses`
|
||||||
|
/// in lockstep with `columns`. Once that lockstep is in place the guard always
|
||||||
|
/// passes and the honor branch is authoritative.
|
||||||
|
fn key_opclasses(index: &EditableStructureIndex, columns: &[String]) -> Vec<Option<String>> {
|
||||||
|
if !index.column_opclasses.is_empty()
|
||||||
|
&& index.column_opclasses.len() == columns.len()
|
||||||
|
&& index.original.as_ref().is_none_or(|original| original.column_opclasses != index.column_opclasses)
|
||||||
|
{
|
||||||
|
return index.column_opclasses.to_vec();
|
||||||
|
}
|
||||||
|
let original = match &index.original {
|
||||||
|
Some(original) if !original.column_opclasses.is_empty() => original,
|
||||||
|
_ => return vec![None; columns.len()],
|
||||||
|
};
|
||||||
|
let mut consumed = vec![false; original.columns.len()];
|
||||||
|
columns
|
||||||
|
.iter()
|
||||||
|
.map(|column| {
|
||||||
|
let claimed = original
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(i, original_column)| !consumed[*i] && *original_column == column);
|
||||||
|
match claimed {
|
||||||
|
Some((i, _)) => {
|
||||||
|
consumed[i] = true;
|
||||||
|
original.column_opclasses.get(i).cloned().flatten()
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn build_drop_index_sql(
|
pub(super) fn build_drop_index_sql(
|
||||||
database_type: Option<DatabaseType>,
|
database_type: Option<DatabaseType>,
|
||||||
dialect: StructureDialect,
|
dialect: StructureDialect,
|
||||||
@@ -278,6 +365,7 @@ pub(super) fn build_create_index_statements(
|
|||||||
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
||||||
let replace = if or_replace { "OR REPLACE " } else { "" };
|
let replace = if or_replace { "OR REPLACE " } else { "" };
|
||||||
let key_is_expression = key_expression_flags(index, &columns);
|
let key_is_expression = key_expression_flags(index, &columns);
|
||||||
|
let key_opclasses = key_opclasses(index, &columns);
|
||||||
let cols = columns
|
let cols = columns
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -287,7 +375,7 @@ pub(super) fn build_create_index_statements(
|
|||||||
} else if dialect == StructureDialect::GaussdbM {
|
} else if dialect == StructureDialect::GaussdbM {
|
||||||
gaussdbm_index_column_sql(column, key_is_expression[i])
|
gaussdbm_index_column_sql(column, key_is_expression[i])
|
||||||
} else if dialect == StructureDialect::Postgres {
|
} else if dialect == StructureDialect::Postgres {
|
||||||
postgres_index_column_sql(column, key_is_expression[i])
|
postgres_index_column_sql(column, key_is_expression[i], key_opclasses[i].as_deref())
|
||||||
} else if for_new_table {
|
} else if for_new_table {
|
||||||
quote_new_ident(database_type, dialect, column)
|
quote_new_ident(database_type, dialect, column)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1694,6 +1694,7 @@ mod tests {
|
|||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
index_type: String::new(),
|
index_type: String::new(),
|
||||||
included_columns: Vec::new(),
|
included_columns: Vec::new(),
|
||||||
|
column_opclasses: Vec::new(),
|
||||||
comment: String::new(),
|
comment: String::new(),
|
||||||
concurrently: false,
|
concurrently: false,
|
||||||
original: None,
|
original: None,
|
||||||
@@ -2013,6 +2014,7 @@ mod tests {
|
|||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
index_type: String::new(),
|
index_type: String::new(),
|
||||||
included_columns: Vec::new(),
|
included_columns: Vec::new(),
|
||||||
|
column_opclasses: Vec::new(),
|
||||||
comment: String::new(),
|
comment: String::new(),
|
||||||
concurrently: false,
|
concurrently: false,
|
||||||
original: Some(IndexInfo {
|
original: Some(IndexInfo {
|
||||||
@@ -2025,6 +2027,7 @@ mod tests {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
}),
|
}),
|
||||||
marked_for_drop: true,
|
marked_for_drop: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ fn index(name: &str, columns: &[&str]) -> EditableStructureIndex {
|
|||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
index_type: String::new(),
|
index_type: String::new(),
|
||||||
included_columns: Vec::new(),
|
included_columns: Vec::new(),
|
||||||
|
column_opclasses: Vec::new(),
|
||||||
comment: String::new(),
|
comment: String::new(),
|
||||||
concurrently: false,
|
concurrently: false,
|
||||||
original: None,
|
original: None,
|
||||||
@@ -140,6 +141,7 @@ fn existing_index(name: &str, columns: &[&str], is_unique: bool) -> EditableStru
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
index
|
index
|
||||||
}
|
}
|
||||||
@@ -231,6 +233,7 @@ fn builds_mysql_column_and_index_changes() {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
let mut email_index = index("uniq_users_email", &["email"]);
|
let mut email_index = index("uniq_users_email", &["email"]);
|
||||||
email_index.is_unique = true;
|
email_index.is_unique = true;
|
||||||
@@ -870,6 +873,7 @@ fn builds_informix_column_and_index_changes() {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
let mut email_index = index("uniq_users_email", &["email"]);
|
let mut email_index = index("uniq_users_email", &["email"]);
|
||||||
email_index.is_unique = true;
|
email_index.is_unique = true;
|
||||||
@@ -1305,6 +1309,7 @@ fn iris_drop_index_includes_table_name() {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||||
@@ -1609,6 +1614,7 @@ fn gbase8a_uses_limited_mysql_ddl() {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||||
@@ -2226,6 +2232,7 @@ fn qualifies_attached_sqlite_table_and_index_changes() {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
let email_index = index("idx_users_email", &["email"]);
|
let email_index = index("idx_users_email", &["email"]);
|
||||||
|
|
||||||
@@ -6728,6 +6735,7 @@ fn oscar_drop_index_with_schema_qualifier() {
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||||
@@ -7184,6 +7192,7 @@ fn gaussdb_m_index(name: &str, columns: &[&str]) -> EditableStructureIndex {
|
|||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
index_type: String::new(),
|
index_type: String::new(),
|
||||||
included_columns: Vec::new(),
|
included_columns: Vec::new(),
|
||||||
|
column_opclasses: Vec::new(),
|
||||||
comment: String::new(),
|
comment: String::new(),
|
||||||
concurrently: false,
|
concurrently: false,
|
||||||
original: None,
|
original: None,
|
||||||
@@ -7210,6 +7219,7 @@ fn gaussdb_m_existing_index(
|
|||||||
included_columns: None,
|
included_columns: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: Vec::new(),
|
||||||
|
column_opclasses: vec![],
|
||||||
});
|
});
|
||||||
idx
|
idx
|
||||||
}
|
}
|
||||||
@@ -7542,3 +7552,244 @@ fn mysql_add_column_nullable_timestamp_without_default_gets_explicit_null() {
|
|||||||
assert_eq!(result.warnings, Vec::<String>::new());
|
assert_eq!(result.warnings, Vec::<String>::new());
|
||||||
assert_eq!(result.statements, vec!["ALTER TABLE `u7_game_order_step` ADD COLUMN `updated_at` timestamp NULL;"]);
|
assert_eq!(result.statements, vec!["ALTER TABLE `u7_game_order_step` ADD COLUMN `updated_at` timestamp NULL;"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PostgreSQL operator class tests ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_gin_index_with_explicit_opclass_generates_opclass_in_ddl() {
|
||||||
|
let mut idx = index("idx_name_trgm", &["name"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())];
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
let sql = result.statements.join("\n");
|
||||||
|
assert!(sql.contains(r#""name" gin_trgm_ops"#), "Expected opclass in DDL, got: {sql}");
|
||||||
|
assert!(sql.contains("USING GIN"), "Expected USING GIN, got: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_opclass_from_original_when_editable_empty() {
|
||||||
|
// When the editable side has no opclass, fall back to original matching.
|
||||||
|
let mut idx = index("idx_name_trgm", &["name"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![]; // empty: fall back to original
|
||||||
|
idx.original = Some(IndexInfo {
|
||||||
|
name: "idx_name_trgm".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![Some("gin_trgm_ops".to_string())],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
let sql = result.statements.join("\n");
|
||||||
|
assert!(sql.contains(r#""name" gin_trgm_ops"#), "Expected opclass from original, got: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_no_rebuild_when_original_has_opclass_and_edit_is_identical() {
|
||||||
|
// When the edited index matches the original (including opclass), no rebuild.
|
||||||
|
let mut idx = index("idx_name_trgm", &["name"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())];
|
||||||
|
idx.original = Some(IndexInfo {
|
||||||
|
name: "idx_name_trgm".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![Some("gin_trgm_ops".to_string())],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
assert!(result.statements.is_empty(), "Expected no rebuild for unchanged index, got: {:?}", result.statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_rebuild_when_opclass_changed() {
|
||||||
|
// When opclass changes, the index should be rebuilt.
|
||||||
|
let mut idx = index("idx_name_trgm", &["name"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())];
|
||||||
|
idx.original = Some(IndexInfo {
|
||||||
|
name: "idx_name_trgm".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![None], // was default, now gin_trgm_ops
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
assert!(!result.statements.is_empty(), "Expected rebuild when opclass changes");
|
||||||
|
let sql = result.statements.join("\n");
|
||||||
|
assert!(sql.contains("DROP INDEX"), "Expected DROP INDEX, got: {sql}");
|
||||||
|
assert!(sql.contains("gin_trgm_ops"), "Expected new opclass in DDL, got: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_gin_varchar_no_opclass_warns() {
|
||||||
|
let mut idx = index("idx_name_search", &["name"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![];
|
||||||
|
|
||||||
|
let mut options = index_change_options(DatabaseType::Postgres, Some("public"), idx);
|
||||||
|
// Add a varchar column for the warning to match against.
|
||||||
|
options.columns.push(EditableStructureColumn {
|
||||||
|
id: "col_name".to_string(),
|
||||||
|
name: "name".to_string(),
|
||||||
|
data_type: "character varying(255)".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
default_value: String::new(),
|
||||||
|
comment: String::new(),
|
||||||
|
is_primary_key: false,
|
||||||
|
extra: None,
|
||||||
|
original: None,
|
||||||
|
original_position: None,
|
||||||
|
marked_for_drop: false,
|
||||||
|
character_set: String::new(),
|
||||||
|
collation: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(options);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.warnings.iter().any(|w| w.contains("operator class") && w.contains("idx_name_search")),
|
||||||
|
"Expected opclass warning for GIN+varchar, got: {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_gin_text_column_with_explicit_opclass_no_warning() {
|
||||||
|
let mut idx = index("idx_body_search", &["body"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())];
|
||||||
|
|
||||||
|
let mut options = index_change_options(DatabaseType::Postgres, Some("public"), idx);
|
||||||
|
options.columns.push(EditableStructureColumn {
|
||||||
|
id: "col_body".to_string(),
|
||||||
|
name: "body".to_string(),
|
||||||
|
data_type: "text".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
default_value: String::new(),
|
||||||
|
comment: String::new(),
|
||||||
|
is_primary_key: false,
|
||||||
|
extra: None,
|
||||||
|
original: None,
|
||||||
|
original_position: None,
|
||||||
|
marked_for_drop: false,
|
||||||
|
character_set: String::new(),
|
||||||
|
collation: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(options);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result.warnings.iter().any(|w| w.contains("operator class")),
|
||||||
|
"Expected NO warning when opclass is set, got: {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_expression_index_appends_opclass() {
|
||||||
|
// The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the
|
||||||
|
// bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the
|
||||||
|
// opclass/COLLATE/DESC block is skipped — see `ruleutils.c` and the note on
|
||||||
|
// `list_indexes_with_sql`). The opclass is read separately from `indclass`
|
||||||
|
// into `column_opclasses`, so the generator must append it to an expression
|
||||||
|
// key just like a real column.
|
||||||
|
let mut idx = index("idx_lower_email", &["lower(email)"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("gin_trgm_ops".to_string())];
|
||||||
|
idx.original = Some(IndexInfo {
|
||||||
|
name: "idx_lower_email".to_string(),
|
||||||
|
columns: vec!["lower(email)".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![true],
|
||||||
|
column_opclasses: vec![None],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
let sql = result.statements.join("\n");
|
||||||
|
assert!(sql.contains("lower(email) gin_trgm_ops"), "Expression should have opclass appended, got: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_jsonb_gin_with_jsonb_path_ops() {
|
||||||
|
let mut idx = index("idx_metadata", &["metadata"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("jsonb_path_ops".to_string())];
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
let sql = result.statements.join("\n");
|
||||||
|
assert!(sql.contains(r#""metadata" jsonb_path_ops"#), "Expected jsonb_path_ops in DDL, got: {sql}");
|
||||||
|
assert!(sql.contains("USING GIN"), "Expected USING GIN, got: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_expression_index_opclass_round_trips_from_indclass() {
|
||||||
|
// Real PostgreSQL introspection: the expression text from
|
||||||
|
// `pg_get_indexdef(indexrelid, colno, false)` is the BARE expression
|
||||||
|
// (`attrsOnly = (colno != 0)` drops the opclass block — see `ruleutils.c`).
|
||||||
|
// The opclass is read separately from `pg_index.indclass` (schema-qualified)
|
||||||
|
// into `column_opclasses`, so the generator appends it to the bare expression
|
||||||
|
// rather than expecting it to be embedded in the text.
|
||||||
|
let mut idx = index("idx_lower_email_trgm", &["lower(email)"]);
|
||||||
|
idx.index_type = "GIN".to_string();
|
||||||
|
idx.column_opclasses = vec![Some("public.gin_trgm_ops".to_string())];
|
||||||
|
idx.original = Some(IndexInfo {
|
||||||
|
name: "idx_lower_email_trgm_old".to_string(), // different name → forces rebuild
|
||||||
|
columns: vec!["lower(email)".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![true],
|
||||||
|
column_opclasses: vec![Some("public.gin_trgm_ops".to_string())],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), idx));
|
||||||
|
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
let sql = result.statements.join("\n");
|
||||||
|
assert!(
|
||||||
|
sql.contains("lower(email) public.gin_trgm_ops"),
|
||||||
|
"Expected bare expression + schema-qualified opclass, got: {sql}"
|
||||||
|
);
|
||||||
|
// The bare expression text no longer carries the opclass, so it cannot be duplicated.
|
||||||
|
assert!(!sql.contains("gin_trgm_ops gin_trgm_ops"), "Opclass must not be duplicated, got: {sql}");
|
||||||
|
}
|
||||||
@@ -94,6 +94,11 @@ pub struct EditableStructureIndex {
|
|||||||
pub index_type: String,
|
pub index_type: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub included_columns: Vec<String>,
|
pub included_columns: Vec<String>,
|
||||||
|
/// Parallel to `columns`: operator class for each key column (PostgreSQL).
|
||||||
|
/// `None` means default operator class. The UI keeps this array in lockstep
|
||||||
|
/// with `columns`; when empty, opclasses fall back to `original` matching.
|
||||||
|
#[serde(default)]
|
||||||
|
pub column_opclasses: Vec<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comment: String,
|
pub comment: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -125,6 +130,9 @@ pub struct IndexInfo {
|
|||||||
/// (e.g. sourced from `pg_get_indexdef`), not a plain column name.
|
/// (e.g. sourced from `pg_get_indexdef`), not a plain column name.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub key_is_expression: Vec<bool>,
|
pub key_is_expression: Vec<bool>,
|
||||||
|
/// Parallel to `columns`: operator class name for each key column, if non-default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub column_opclasses: Vec<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub(super) fn validate_draft(options: &TableStructureSqlOptions) -> Vec<String>
|
|||||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||||
validate_columns(&active_columns, &mut warnings);
|
validate_columns(&active_columns, &mut warnings);
|
||||||
validate_dameng_identity(options, &active_columns, &mut warnings);
|
validate_dameng_identity(options, &active_columns, &mut warnings);
|
||||||
|
validate_gin_opclass_warnings(options, &mut warnings);
|
||||||
for index in options
|
for index in options
|
||||||
.indexes
|
.indexes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -101,3 +102,67 @@ pub(super) fn validate_columns(columns: &[&EditableStructureColumn], warnings: &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PostgreSQL GIN indexes on varchar/text/character columns without an explicit
|
||||||
|
/// operator class are a common trap: the default `array_ops` class is rarely what
|
||||||
|
/// the user wants, and `gin_trgm_ops` (from `pg_trgm` extension) or similar
|
||||||
|
/// classes are almost always needed. This is a warning, not an error, because
|
||||||
|
/// the default class may be deliberate in some cases.
|
||||||
|
fn validate_gin_opclass_warnings(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) {
|
||||||
|
let capabilities = capabilities_for(options.database_type);
|
||||||
|
if capabilities.dialect != StructureDialect::Postgres {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a lookup: column name (lowercased) → data type for active columns.
|
||||||
|
let col_types: std::collections::HashMap<String, &str> = options
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.filter(|col| !col.marked_for_drop)
|
||||||
|
.map(|col| (clean(&col.name).to_lowercase(), col.data_type.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let text_like = |dt: &str| -> bool {
|
||||||
|
let lower = dt.to_lowercase();
|
||||||
|
lower == "text"
|
||||||
|
|| lower.starts_with("character varying")
|
||||||
|
|| lower.starts_with("varchar")
|
||||||
|
|| lower.starts_with("character")
|
||||||
|
|| lower == "char"
|
||||||
|
};
|
||||||
|
|
||||||
|
for index in options.indexes.iter().filter(|idx| {
|
||||||
|
!idx.marked_for_drop
|
||||||
|
&& clean(&idx.index_type).eq_ignore_ascii_case("GIN")
|
||||||
|
&& (idx.original.is_none() || has_existing_index_change(idx))
|
||||||
|
}) {
|
||||||
|
for (i, col) in index.columns.iter().enumerate() {
|
||||||
|
let col_name = clean(col);
|
||||||
|
if col_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Expression columns are not matched against table column types.
|
||||||
|
let is_expr =
|
||||||
|
index.original.as_ref().and_then(|orig| orig.key_is_expression.get(i).copied()).unwrap_or(false);
|
||||||
|
if is_expr {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check if this column has a text-like type.
|
||||||
|
let Some(dt) = col_types.get(&col_name.to_lowercase()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !text_like(dt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check if opclass is explicitly provided.
|
||||||
|
let has_opclass =
|
||||||
|
index.column_opclasses.get(i).and_then(|o| o.as_deref()).map(|v| !clean(v).is_empty()).unwrap_or(false);
|
||||||
|
if !has_opclass {
|
||||||
|
warnings.push(format!(
|
||||||
|
"GIN index \"{}\" on column \"{}\" (type {}) has no operator class specified. Consider adding an operator class, e.g. `gin_trgm_ops` (requires pg_trgm extension) for text search.",
|
||||||
|
index.name, col_name, dt
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-19
@@ -1276,17 +1276,6 @@ fn query_result_has_rows(result: &db::QueryResult) -> bool {
|
|||||||
!result.rows.is_empty()
|
!result.rows.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_simple_identifier(value: &str) -> bool {
|
|
||||||
let mut chars = value.chars();
|
|
||||||
let Some(first) = chars.next() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_postgres_compat_transfer(source_db: &DatabaseType, target_db: &DatabaseType) -> bool {
|
fn is_postgres_compat_transfer(source_db: &DatabaseType, target_db: &DatabaseType) -> bool {
|
||||||
is_postgres_transfer_dialect(source_db) && is_postgres_transfer_dialect(target_db)
|
is_postgres_transfer_dialect(source_db) && is_postgres_transfer_dialect(target_db)
|
||||||
}
|
}
|
||||||
@@ -2014,11 +2003,18 @@ fn sqlserver_row_number_page_sql(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn postgres_index_column_sql(column: &str) -> String {
|
fn postgres_index_column_sql(column: &str, is_expression: bool, opclass: Option<&str>) -> String {
|
||||||
if is_simple_identifier(column) {
|
// The base key text: a real column is quoted as an identifier; an expression/functional
|
||||||
quote_identifier(column, &DatabaseType::Postgres)
|
// key part arrives as raw expression text (the per-column `pg_get_indexdef` omits the
|
||||||
} else {
|
// opclass — see `crates/dbx-core/src/db/postgres.rs`), so quoting the whole thing as
|
||||||
column.to_string()
|
// an identifier would turn it into a nonexistent column reference (#6295).
|
||||||
|
let base = if is_expression { column.to_string() } else { quote_identifier(column, &DatabaseType::Postgres) };
|
||||||
|
// The opclass is read separately from `pg_index.indclass` for every key position
|
||||||
|
// (including expression keys) and appended uniformly — it never lives inside the
|
||||||
|
// expression text, so there is no duplication risk.
|
||||||
|
match opclass.filter(|o| !o.is_empty()) {
|
||||||
|
Some(opc) => format!("{base} {opc}"),
|
||||||
|
None => base,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2037,8 +2033,17 @@ fn generate_postgres_index_ddl(indexes: &[db::IndexInfo], table: &str, schema: &
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(|value| format!(" USING {value}"))
|
.map(|value| format!(" USING {value}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let columns =
|
let columns = index
|
||||||
index.columns.iter().map(|column| postgres_index_column_sql(column)).collect::<Vec<_>>().join(", ");
|
.columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, column)| {
|
||||||
|
let is_expr = index.key_is_expression.get(i).copied().unwrap_or(false);
|
||||||
|
let opclass = index.column_opclasses.get(i).and_then(|o| o.as_deref());
|
||||||
|
postgres_index_column_sql(column, is_expr, opclass)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
let include_clause = index
|
let include_clause = index
|
||||||
.included_columns
|
.included_columns
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -11664,7 +11669,8 @@ mod tests {
|
|||||||
index_type: Some("btree".to_string()),
|
index_type: Some("btree".to_string()),
|
||||||
included_columns: Some(vec!["created_at".to_string()]),
|
included_columns: Some(vec!["created_at".to_string()]),
|
||||||
comment: Some("lookup index".to_string()),
|
comment: Some("lookup index".to_string()),
|
||||||
key_is_expression: Vec::new(),
|
key_is_expression: vec![true],
|
||||||
|
column_opclasses: vec![],
|
||||||
}];
|
}];
|
||||||
let foreign_keys = vec![
|
let foreign_keys = vec![
|
||||||
db::ForeignKeyInfo {
|
db::ForeignKeyInfo {
|
||||||
@@ -11705,6 +11711,128 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_ddl_includes_opclass_for_gin_trigram() {
|
||||||
|
let indexes = vec![db::IndexInfo {
|
||||||
|
name: "users_name_trgm_idx".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![Some("gin_trgm_ops".to_string())],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let sql = generate_postgres_index_ddl(&indexes, "users", "public");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
vec!["CREATE INDEX IF NOT EXISTS \"users_name_trgm_idx\" ON \"public\".\"users\" USING gin (\"name\" gin_trgm_ops)".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_ddl_no_opclass_when_all_default() {
|
||||||
|
let indexes = vec![db::IndexInfo {
|
||||||
|
name: "users_name_idx".to_string(),
|
||||||
|
columns: vec!["name".to_string(), "status".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: None,
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false, false],
|
||||||
|
column_opclasses: vec![None, None],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let sql = generate_postgres_index_ddl(&indexes, "users", "public");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
vec!["CREATE INDEX IF NOT EXISTS \"users_name_idx\" ON \"public\".\"users\" (\"name\", \"status\")"
|
||||||
|
.to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_ddl_expression_appends_opclass() {
|
||||||
|
// The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the
|
||||||
|
// bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the opclass
|
||||||
|
// block is skipped — see `ruleutils.c`). The opclass is read separately from
|
||||||
|
// `indclass` into `column_opclasses`, so transfer DDL must append it to an
|
||||||
|
// expression key just like a real column.
|
||||||
|
let indexes = vec![db::IndexInfo {
|
||||||
|
name: "users_lower_email_idx".to_string(),
|
||||||
|
columns: vec!["lower(email)".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("gin".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![true],
|
||||||
|
column_opclasses: vec![Some("gin_trgm_ops".to_string())],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let sql = generate_postgres_index_ddl(&indexes, "users", "public");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
vec!["CREATE INDEX IF NOT EXISTS \"users_lower_email_idx\" ON \"public\".\"users\" USING gin (lower(email) gin_trgm_ops)"
|
||||||
|
.to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_ddl_mixed_opclass_and_default() {
|
||||||
|
let indexes = vec![db::IndexInfo {
|
||||||
|
name: "users_name_status_idx".to_string(),
|
||||||
|
columns: vec!["name".to_string(), "status".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: Some("btree".to_string()),
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false, false],
|
||||||
|
column_opclasses: vec![Some("text_pattern_ops".to_string()), None],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let sql = generate_postgres_index_ddl(&indexes, "users", "public");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
vec!["CREATE INDEX IF NOT EXISTS \"users_name_status_idx\" ON \"public\".\"users\" USING btree (\"name\" text_pattern_ops, \"status\")".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_index_ddl_empty_column_opclasses_vec_falls_back_to_no_opclass() {
|
||||||
|
let indexes = vec![db::IndexInfo {
|
||||||
|
name: "users_name_idx".to_string(),
|
||||||
|
columns: vec!["name".to_string()],
|
||||||
|
is_unique: false,
|
||||||
|
is_primary: false,
|
||||||
|
filter: None,
|
||||||
|
index_type: None,
|
||||||
|
included_columns: None,
|
||||||
|
comment: None,
|
||||||
|
key_is_expression: vec![false],
|
||||||
|
column_opclasses: vec![],
|
||||||
|
}];
|
||||||
|
|
||||||
|
let sql = generate_postgres_index_ddl(&indexes, "users", "public");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
vec!["CREATE INDEX IF NOT EXISTS \"users_name_idx\" ON \"public\".\"users\" (\"name\")".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn postgres_sequence_sync_sql_uses_table_max_values() {
|
fn postgres_sequence_sync_sql_uses_table_max_values() {
|
||||||
let sql = generate_postgres_sequence_sync_sql(
|
let sql = generate_postgres_sequence_sync_sql(
|
||||||
|
|||||||
@@ -456,6 +456,11 @@ pub struct IndexInfo {
|
|||||||
/// introspection source doesn't track this (provenance unknown for that dialect/path).
|
/// introspection source doesn't track this (provenance unknown for that dialect/path).
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub key_is_expression: Vec<bool>,
|
pub key_is_expression: Vec<bool>,
|
||||||
|
/// Parallel to `columns`: operator class name for each key column, if non-default.
|
||||||
|
/// For example, a GIN trigram index on a varchar column will have `"gin_trgm_ops"`.
|
||||||
|
/// `None` means the default operator class is used (can be omitted in DDL).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub column_opclasses: Vec<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ fn concurrent_index(columns: Vec<&str>) -> EditableStructureIndex {
|
|||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
index_type: String::new(),
|
index_type: String::new(),
|
||||||
included_columns: Vec::new(),
|
included_columns: Vec::new(),
|
||||||
|
column_opclasses: Vec::new(),
|
||||||
comment: String::new(),
|
comment: String::new(),
|
||||||
concurrently: true,
|
concurrently: true,
|
||||||
original: None,
|
original: None,
|
||||||
|
|||||||
Reference in new issue
Block a user