Files
dbx/apps/desktop/src/lib/sql/sqlNavigation.ts
T
Yy-702 e749bdd42c feat(oracle): Ctrl/Cmd+click 打开存储过程源码
* feat(oracle): open procedure source via Ctrl/Cmd+click in SQL editor

Allow navigating from SQL identifiers to routine source like DataGrip/DBeaver.
Call sites open immediately without a global ALL_OBJECTS scan, and query-tab
views wrap bare Oracle ALL_SOURCE as CREATE OR REPLACE for editable DDL.

Close #5290

* fix(oracle): harden Ctrl/Cmd+click navigation identity model

Address review feedback on routine navigation correctness:
- Distinguish relation column lists (INSERT INTO t(...)) from routine calls
- Resolve schema.routine vs package.member before optimistic open
- Preserve quoted mixed-case identities for Oracle ALL_SOURCE lookup
- Try exact then uppercase name candidates in the Oracle Go agent
2026-08-05 23:57:50 +08:00

713 lines
24 KiB
TypeScript

/**
* Utilities for SQL identifier navigation (Ctrl/Cmd + click on table/column names).
*/
import type { ObjectSourceKind } from "@/types/database";
const SQL_KEYWORDS_SET = new Set([
"select",
"from",
"where",
"join",
"left",
"right",
"inner",
"outer",
"on",
"group",
"by",
"order",
"asc",
"desc",
"having",
"limit",
"offset",
"insert",
"into",
"values",
"update",
"set",
"delete",
"create",
"table",
"view",
"as",
"and",
"or",
"not",
"in",
"is",
"null",
"like",
"distinct",
"union",
"all",
"exists",
"between",
"case",
"when",
"then",
"else",
"end",
"count",
"sum",
"avg",
"min",
"max",
"coalesce",
"cast",
"alter",
"drop",
"add",
"column",
"index",
"primary",
"key",
"foreign",
"references",
"constraint",
"default",
"check",
"unique",
"begin",
"commit",
"rollback",
"truncate",
"explain",
"analyze",
"with",
"recursive",
"over",
"partition",
"row_number",
"rank",
"dense_rank",
"lag",
"lead",
"first_value",
"last_value",
"ntile",
"cross",
"full",
"natural",
"using",
"lateral",
"unnest",
"filter",
"exclude",
"replace",
"qualify",
"pivot",
"unpivot",
"asof",
"positional",
"anti",
"semi",
"sample",
"struct",
"map",
"list",
"array",
"lambda",
"copy",
"export",
"import",
"describe",
"show",
"summarize",
"pragma",
"tablesample",
"read_csv",
"read_parquet",
"read_json",
"list_transform",
]);
type IdentifierPart = { value: string; start: number; end: number; quoted: boolean };
export interface ExtractedSqlIdentifier {
identifier: string;
quoted: boolean;
}
export interface ExtractedSqlIdentifierPart {
value: string;
quoted: boolean;
}
export type SqlObjectNavigationType = "table" | "view" | "materialized_view" | "procedure" | "function" | "package" | "trigger";
/**
* Semantic role of a Ctrl/Cmd+clicked identifier for navigation.
*
* - `relation_column_list`: name is a table/view followed by `(cols)` (INSERT INTO t(...), CREATE TABLE t(...), …)
* - `routine_call`: name is invoked as a call / PL/SQL unit (`proc(...)`, `pkg.member(...)`)
* - `unknown`: plain identifier; resolve via metadata (table first, then routine)
*/
export type SqlObjectNavigationRole = "relation_column_list" | "routine_call" | "unknown";
export interface SqlObjectNavigationTarget {
name: string;
database?: string;
schema?: string;
type?: SqlObjectNavigationType;
/** Overload signature used by Postgres/SQL Server routines. */
signature?: string;
/** Owning package/type name for Oracle package members (or trigger parent table). */
parentName?: string;
parentSchema?: string;
/** Quote metadata so Oracle keeps mixed-case identities (`"MiXeDProc"`). */
nameQuoted?: boolean;
schemaQuoted?: boolean;
parentNameQuoted?: boolean;
parentSchemaQuoted?: boolean;
}
/**
* Fully resolved click identity: parts, quotes, and navigation role.
* Built before any remote metadata call so call-site vs table and package vs schema
* decisions share one model instead of scattered if/else branches.
*/
export interface SqlObjectNavigationIdentity {
identifier: string;
parts: ExtractedSqlIdentifierPart[];
start: number;
end: number;
name: string;
nameQuoted: boolean;
/** Leading qualifier for 2-part names (schema OR package — still ambiguous until metadata). */
qualifier?: string;
qualifierQuoted?: boolean;
/** Owner/schema when 3-part (schema.package.member) or when metadata resolves schema.routine. */
schema?: string;
schemaQuoted?: boolean;
role: SqlObjectNavigationRole;
/**
* Two-part `A.B(...)` is ambiguous between `schema.routine` and `package.member`.
* Optimistic open prefers package.member under the session schema (Oracle common case)
* only after metadata lookups fail.
*/
twoPartAmbiguous: boolean;
followedByParen: boolean;
}
export function sqlObjectNavigationTarget(table: SqlObjectNavigationTarget): SqlObjectNavigationTarget {
return {
name: table.name,
...(table.database ? { database: table.database } : {}),
...(table.schema ? { schema: table.schema } : {}),
...(table.type ? { type: table.type } : {}),
...(table.signature ? { signature: table.signature } : {}),
...(table.parentName ? { parentName: table.parentName } : {}),
...(table.parentSchema ? { parentSchema: table.parentSchema } : {}),
...(table.nameQuoted ? { nameQuoted: true } : {}),
...(table.schemaQuoted ? { schemaQuoted: true } : {}),
...(table.parentNameQuoted ? { parentNameQuoted: true } : {}),
...(table.parentSchemaQuoted ? { parentSchemaQuoted: true } : {}),
};
}
export function isSqlObjectNavigationRoutineType(type?: SqlObjectNavigationType): boolean {
return type === "procedure" || type === "function" || type === "package" || type === "trigger";
}
export function sqlObjectHoverDetail(table: SqlObjectNavigationTarget): string {
const objectType = table.type === "materialized_view" ? "materialized view" : table.type === "view" ? "view" : table.type === "procedure" ? "procedure" : table.type === "function" ? "function" : table.type === "package" ? "package" : table.type === "trigger" ? "trigger" : "table";
if (table.parentName) {
const owner = table.parentSchema ? `${table.parentSchema}.${table.parentName}` : table.parentName;
return `${objectType} in ${owner}`;
}
return table.schema ? `${objectType} in ${table.schema}` : objectType;
}
export function sqlObjectNavigationTableType(table: SqlObjectNavigationTarget): "TABLE" | "VIEW" | "MATERIALIZED_VIEW" {
if (table.type === "materialized_view") return "MATERIALIZED_VIEW";
return table.type === "view" ? "VIEW" : "TABLE";
}
/**
* Maps a navigation target to an object-source kind.
*
* Package members (procedure/function with parentName) resolve to PACKAGE_BODY so Oracle
* ALL_SOURCE can load the owning package body rather than a missing standalone routine.
*/
export function sqlObjectNavigationSourceKind(table: SqlObjectNavigationTarget): ObjectSourceKind | undefined {
if (table.parentName && (table.type === "procedure" || table.type === "function")) {
return "PACKAGE_BODY";
}
switch (table.type) {
case "view":
return "VIEW";
case "materialized_view":
return "MATERIALIZED_VIEW";
case "procedure":
return "PROCEDURE";
case "function":
return "FUNCTION";
case "package":
return "PACKAGE";
case "trigger":
return "TRIGGER";
default:
return undefined;
}
}
/** Object name to pass to getObjectSource (package body uses the package name). */
export function sqlObjectNavigationSourceName(table: SqlObjectNavigationTarget): string {
if (table.parentName && (table.type === "procedure" || table.type === "function")) {
return table.parentName;
}
return table.name;
}
/** Schema/owner for getObjectSource. */
export function sqlObjectNavigationSourceSchema(table: SqlObjectNavigationTarget, fallbackSchema?: string): string | undefined {
if (table.parentName && (table.type === "procedure" || table.type === "function")) {
return table.parentSchema || table.schema || fallbackSchema;
}
return table.schema || fallbackSchema;
}
export function sqlObjectNavigationTypeFromTableType(tableType: string | null | undefined): SqlObjectNavigationType {
const normalized = tableType
?.trim()
.toUpperCase()
.replace(/[\s-]+/g, "_");
if (normalized === "MATERIALIZED_VIEW") return "materialized_view";
if (normalized === "VIEW") return "view";
if (normalized === "PROCEDURE") return "procedure";
if (normalized === "FUNCTION") return "function";
if (normalized === "PACKAGE" || normalized === "PACKAGE_BODY") return "package";
if (normalized === "TRIGGER") return "trigger";
return "table";
}
export function sqlObjectNavigationTypeFromCompletionObjectType(type: string | null | undefined): SqlObjectNavigationType | undefined {
switch (type) {
case "procedure":
return "procedure";
case "function":
return "function";
case "package":
return "package";
case "trigger":
return "trigger";
default:
return undefined;
}
}
export function mergeSqlObjectNavigationType(left?: SqlObjectNavigationType, right?: SqlObjectNavigationType): SqlObjectNavigationType | undefined {
if (left === "materialized_view" || right === "materialized_view") return "materialized_view";
if (left === "view" || right === "view") return "view";
if (isSqlObjectNavigationRoutineType(left)) return left;
if (isSqlObjectNavigationRoutineType(right)) return right;
return left ?? right;
}
function isIdentifierChar(char: string | undefined): boolean {
return !!char && /^[A-Za-z0-9_$]$/.test(char);
}
function readQuotedPart(text: string, start: number): IdentifierPart | null {
const open = text[start];
const close = open === "[" ? "]" : open;
if (open !== "`" && open !== '"' && open !== "[") return null;
let value = "";
for (let i = start + 1; i < text.length; i += 1) {
const char = text[i];
if (char === close) {
if (text[i + 1] === close) {
value += close;
i += 1;
continue;
}
return { value, start, end: i + 1, quoted: true };
}
value += char;
}
return null;
}
function readUnquotedPart(text: string, start: number): IdentifierPart | null {
if (!isIdentifierChar(text[start])) return null;
let end = start + 1;
while (end < text.length && isIdentifierChar(text[end])) end += 1;
return { value: text.slice(start, end), start, end, quoted: false };
}
function readIdentifierPart(text: string, start: number): IdentifierPart | null {
return readQuotedPart(text, start) ?? readUnquotedPart(text, start);
}
function parseQualifiedIdentifier(text: string, start: number): { parts: IdentifierPart[]; start: number; end: number } | null {
const first = readIdentifierPart(text, start);
if (!first) return null;
const parts = [first];
let end = first.end;
while (text[end] === ".") {
const next = readIdentifierPart(text, end + 1);
if (!next) break;
parts.push(next);
end = next.end;
}
return { parts, start, end };
}
function identifierSearchBounds(doc: string, pos: number): { start: number; end: number } {
let start = pos;
while (start > 0 && doc[start - 1] !== "\n" && doc[start - 1] !== "\r") start -= 1;
let end = pos;
while (end < doc.length && doc[end] !== "\n" && doc[end] !== "\r") end += 1;
return { start, end };
}
/** Locate the qualified identifier covering `pos`, including quote flags and document range. */
export function extractQualifiedIdentifierAt(doc: string, pos: number): { parts: ExtractedSqlIdentifierPart[]; start: number; end: number } | null {
if (pos < 0 || pos > doc.length) return null;
const clickPos = pos === doc.length ? pos - 1 : pos;
if (clickPos < 0) return null;
const bounds = identifierSearchBounds(doc, clickPos);
let index = bounds.start;
while (index < bounds.end) {
const parsed = parseQualifiedIdentifier(doc, index);
if (parsed) {
if (clickPos >= parsed.start && clickPos < parsed.end) {
return {
parts: parsed.parts.map((part) => ({ value: part.value, quoted: part.quoted })),
start: parsed.start,
end: parsed.end,
};
}
index = Math.max(parsed.end, index + 1);
continue;
}
index += 1;
}
return null;
}
/** Extract qualified identifier parts and per-part quote metadata at position `pos`. */
export function extractIdentifierPartsAt(doc: string, pos: number): ExtractedSqlIdentifierPart[] {
return extractQualifiedIdentifierAt(doc, pos)?.parts ?? [];
}
/** Extract identifier and quote metadata at position `pos` in the document. */
export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedSqlIdentifier | null {
const parts = extractIdentifierPartsAt(doc, pos);
if (parts.length === 0) return null;
return {
identifier: parts.map((part) => part.value).join("."),
quoted: parts.some((part) => part.quoted),
};
}
/** Extract identifier at position `pos` in the document. */
export function extractIdentifierAt(doc: string, pos: number): string | null {
return extractIdentifierDetailsAt(doc, pos)?.identifier ?? null;
}
/**
* True when the identifier at `pos` is immediately followed by `(` (after optional whitespace).
* Not sufficient alone for routine navigation — also see {@link isSqlRelationColumnListContext}.
*/
export function isSqlCallSiteIdentifierAt(doc: string, pos: number): boolean {
const located = extractQualifiedIdentifierAt(doc, pos);
if (!located) return false;
let cursor = located.end;
while (cursor < doc.length && /\s/.test(doc[cursor] ?? "")) cursor += 1;
return doc[cursor] === "(";
}
/**
* True when `before` (text immediately before a qualified identifier) is a relation definition
* / DML target where a following `(` starts a column list, not a routine call.
*
* Examples: `INSERT INTO t(`, `CREATE TABLE t(`, `CREATE OR REPLACE VIEW v(`, `CREATE INDEX i ON t(`.
*/
export function isSqlRelationColumnListContext(beforeIdentifier: string): boolean {
const cleaned = beforeIdentifier
.replace(/--[^\n]*/g, " ")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/\s+/g, " ")
.trimEnd();
// Trailing space optional; match statement keywords that introduce a relation then `(`.
return /(?:^|[\s(])(?:insert\s+into|merge\s+into|update|create\s+(?:global\s+temporary\s+)?table|create\s+(?:or\s+replace\s+)?(?:(?:no)?force\s+)?(?:(?:non)?editionable\s+)?view|create\s+(?:unique\s+|bitmap\s+)?index(?:\s+(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))?\s+on|alter\s+table)$/i.test(
cleaned,
);
}
/**
* True when the identifier is a routine-call navigation candidate (not a relation column list).
* `INSERT INTO ORDERS(ID)` → false; `BEGIN PROC_NAME()` → true.
*/
export function isSqlRoutineCallNavigationCandidate(doc: string, pos: number): boolean {
const located = extractQualifiedIdentifierAt(doc, pos);
if (!located) return false;
let cursor = located.end;
while (cursor < doc.length && /\s/.test(doc[cursor] ?? "")) cursor += 1;
if (doc[cursor] !== "(") return false;
return !isSqlRelationColumnListContext(doc.slice(0, located.start));
}
/**
* Resolve the full navigation identity at a click position (role + quote-aware parts).
*/
export function resolveSqlObjectNavigationIdentity(doc: string, pos: number): SqlObjectNavigationIdentity | null {
const located = extractQualifiedIdentifierAt(doc, pos);
if (!located || located.parts.length === 0) return null;
const parts = located.parts;
const namePart = parts[parts.length - 1];
if (!namePart) return null;
if (!namePart.quoted && isSqlKeyword(namePart.value)) return null;
let cursor = located.end;
while (cursor < doc.length && /\s/.test(doc[cursor] ?? "")) cursor += 1;
const followedByParen = doc[cursor] === "(";
const relationColumnList = followedByParen && isSqlRelationColumnListContext(doc.slice(0, located.start));
let role: SqlObjectNavigationRole = "unknown";
if (relationColumnList) role = "relation_column_list";
else if (followedByParen) role = "routine_call";
const identity: SqlObjectNavigationIdentity = {
identifier: parts.map((part) => part.value).join("."),
parts,
start: located.start,
end: located.end,
name: namePart.value,
nameQuoted: namePart.quoted,
role,
twoPartAmbiguous: parts.length === 2 && role === "routine_call",
followedByParen,
};
if (parts.length >= 3) {
const schemaPart = parts[parts.length - 3];
const packagePart = parts[parts.length - 2];
if (schemaPart) {
identity.schema = schemaPart.value;
identity.schemaQuoted = schemaPart.quoted;
}
if (packagePart) {
identity.qualifier = packagePart.value;
identity.qualifierQuoted = packagePart.quoted;
}
} else if (parts.length === 2) {
const qualifierPart = parts[0];
if (qualifierPart) {
identity.qualifier = qualifierPart.value;
identity.qualifierQuoted = qualifierPart.quoted;
}
}
return identity;
}
/**
* Build a navigation target from a resolved identity for optimistic routine open.
*
* - 1-part: session/fallback schema + procedure name
* - 2-part ambiguous: prefer package.member under fallback schema (not schema.routine)
* - 3-part: schema.package.member
*/
export function sqlObjectNavigationTargetFromIdentity(
identity: SqlObjectNavigationIdentity,
options?: {
fallbackSchema?: string;
preferType?: SqlObjectNavigationType;
/** Force schema.routine interpretation for 2-part names (metadata-confirmed). */
asSchemaRoutine?: boolean;
/** Force package.member interpretation (default for ambiguous 2-part optimistic). */
asPackageMember?: boolean;
signature?: string;
},
): SqlObjectNavigationTarget | null {
if (!identity.name) return null;
const preferType = options?.preferType ?? "procedure";
const type: SqlObjectNavigationType = preferType === "function" ? "function" : preferType === "package" ? "package" : "procedure";
if (identity.parts.length >= 3) {
return sqlObjectNavigationTarget({
name: identity.name,
nameQuoted: identity.nameQuoted,
schema: identity.schema,
schemaQuoted: identity.schemaQuoted,
parentName: identity.qualifier,
parentNameQuoted: identity.qualifierQuoted,
parentSchema: identity.schema,
parentSchemaQuoted: identity.schemaQuoted,
type,
...(options?.signature ? { signature: options.signature } : {}),
});
}
if (identity.parts.length === 2 && identity.qualifier) {
const asPackageMember = options?.asPackageMember ?? (!options?.asSchemaRoutine && identity.twoPartAmbiguous);
if (asPackageMember) {
return sqlObjectNavigationTarget({
name: identity.name,
nameQuoted: identity.nameQuoted,
schema: options?.fallbackSchema,
parentName: identity.qualifier,
parentNameQuoted: identity.qualifierQuoted,
parentSchema: options?.fallbackSchema,
type,
...(options?.signature ? { signature: options.signature } : {}),
});
}
return sqlObjectNavigationTarget({
name: identity.name,
nameQuoted: identity.nameQuoted,
schema: identity.qualifier,
schemaQuoted: identity.qualifierQuoted,
type,
...(options?.signature ? { signature: options.signature } : {}),
});
}
return sqlObjectNavigationTarget({
name: identity.name,
nameQuoted: identity.nameQuoted,
...(options?.fallbackSchema ? { schema: options.fallbackSchema } : {}),
type,
...(options?.signature ? { signature: options.signature } : {}),
});
}
/**
* @deprecated Prefer {@link sqlObjectNavigationTargetFromIdentity} so quote flags and package/schema
* ambiguity stay consistent with the click identity model.
*/
export function sqlObjectNavigationTargetFromIdentifier(
identifier: string,
options?: {
fallbackSchema?: string;
preferType?: SqlObjectNavigationType;
},
): SqlObjectNavigationTarget | null {
// Synthesize an identity for string-only callers (tests / legacy).
const syntheticDoc = identifier;
const identity = resolveSqlObjectNavigationIdentity(syntheticDoc, Math.max(0, syntheticDoc.length - 1));
if (!identity) return null;
// String-only path has no surrounding SQL; treat as routine when multi-part.
if (identity.parts.length >= 2) {
identity.role = "routine_call";
identity.twoPartAmbiguous = identity.parts.length === 2;
identity.followedByParen = true;
}
return sqlObjectNavigationTargetFromIdentity(identity, options);
}
/**
* Oracle stores unquoted identifiers as uppercase. Quoted identities keep their written case.
* Apply before getObjectSource so mixed-case `"MiXeDProc"` is not forced to `MIXEDPROC`.
*/
export function normalizeOracleNavigationIdentityName(name: string, quoted?: boolean): string {
return quoted ? name : name.toUpperCase();
}
/** Normalize schema/name/parent fields on a navigation target for Oracle metadata APIs. */
export function normalizeOracleNavigationTarget(target: SqlObjectNavigationTarget): SqlObjectNavigationTarget {
return sqlObjectNavigationTarget({
...target,
name: normalizeOracleNavigationIdentityName(target.name, target.nameQuoted),
...(target.schema != null ? { schema: normalizeOracleNavigationIdentityName(target.schema, target.schemaQuoted) } : {}),
...(target.parentName != null ? { parentName: normalizeOracleNavigationIdentityName(target.parentName, target.parentNameQuoted) } : {}),
...(target.parentSchema != null ? { parentSchema: normalizeOracleNavigationIdentityName(target.parentSchema, target.parentSchemaQuoted) } : {}),
});
}
/** Check whether the identifier is a SQL keyword (not a table/column name). */
export function isSqlKeyword(identifier: string): boolean {
return SQL_KEYWORDS_SET.has(identifier.toLowerCase());
}
export function splitQualifiedIdentifier(identifier: string): string[] {
const trimmed = identifier.trim();
if (!trimmed) return [];
const parsed = parseQualifiedIdentifier(trimmed, 0);
if (!parsed || parsed.end !== trimmed.length) return [trimmed];
return parsed.parts.map((part) => part.value);
}
/** Match identifier against known table names (case-insensitive). Supports qualified identifiers like schema.table. */
export function matchTable<T extends { name: string; database?: string; schema?: string }>(identifier: string, tables: T[]): T | null {
const parts = splitQualifiedIdentifier(identifier);
const normalizedIdentifier = parts.length > 0 ? parts.join(".").toLowerCase() : identifier.toLowerCase();
const direct = tables.find((t) => t.name.toLowerCase() === normalizedIdentifier);
if (direct) return direct;
if (parts.length >= 2) {
// Use the final two parts so catalog.schema.table still resolves against schema-scoped metadata.
const qualifier = parts[parts.length - 2].toLowerCase();
const name = parts[parts.length - 1].toLowerCase();
const database = parts.length >= 3 ? parts[parts.length - 3].toLowerCase() : undefined;
const qualified = tables.find((t) => t.name.toLowerCase() === name && t.schema?.toLowerCase() === qualifier && (!database || !t.database || t.database.toLowerCase() === database));
if (qualified) return qualified;
}
return null;
}
type MatchableSqlObject = {
name: string;
schema?: string;
parentName?: string;
parentSchema?: string;
};
/**
* Match identifier against completion routines (procedure / function / package / trigger).
*
* Supports:
* - `proc`
* - `schema.proc`
* - `package.proc` (package member)
* - `schema.package.proc`
*/
export function matchSqlObject<T extends MatchableSqlObject>(identifier: string, objects: readonly T[]): T | null {
const parts = splitQualifiedIdentifier(identifier);
if (parts.length === 0) return null;
const name = parts[parts.length - 1].toLowerCase();
const same = (left: string | undefined, right: string | undefined) => (left || "").toLowerCase() === (right || "").toLowerCase();
if (parts.length === 1) {
// Prefer standalone routines over package members when the identifier is unqualified.
return objects.find((object) => object.name.toLowerCase() === name && !object.parentName) ?? objects.find((object) => object.name.toLowerCase() === name) ?? null;
}
if (parts.length === 2) {
const qualifier = parts[0].toLowerCase();
// schema.object (standalone)
const bySchema = objects.find((object) => object.name.toLowerCase() === name && !object.parentName && object.schema?.toLowerCase() === qualifier) ?? objects.find((object) => object.name.toLowerCase() === name && object.schema?.toLowerCase() === qualifier && !object.parentName);
if (bySchema) return bySchema;
// package.member
const byPackage = objects.find((object) => object.name.toLowerCase() === name && object.parentName?.toLowerCase() === qualifier);
if (byPackage) return byPackage;
return null;
}
// schema.package.member or catalog.schema.object — use the last three parts.
const owner = parts[parts.length - 3].toLowerCase();
const middle = parts[parts.length - 2].toLowerCase();
const packageMember = objects.find((object) => object.name.toLowerCase() === name && object.parentName?.toLowerCase() === middle && same(object.parentSchema || object.schema, owner));
if (packageMember) return packageMember;
// Fallback: schema.object ignoring extra leading catalog parts.
return objects.find((object) => object.name.toLowerCase() === name && !object.parentName && object.schema?.toLowerCase() === middle) ?? null;
}