@aws-amplify/datastore
Version:
AppSyncLocal support for aws-amplify
932 lines (799 loc) • 25.5 kB
text/typescript
import { GRAPHQL_AUTH_MODE } from '@aws-amplify/api-graphql';
import { GraphQLAuthError } from '@aws-amplify/api';
import { Logger } from '@aws-amplify/core';
import { ModelInstanceCreator } from '../datastore/datastore';
import {
AuthorizationRule,
GraphQLCondition,
GraphQLFilter,
GraphQLField,
isEnumFieldType,
isGraphQLScalarType,
isPredicateObj,
isSchemaModel,
isSchemaModelWithAttributes,
isTargetNameAssociation,
isNonModelFieldType,
ModelFields,
ModelInstanceMetadata,
OpType,
PersistentModel,
PersistentModelConstructor,
PredicatesGroup,
PredicateObject,
RelationshipType,
SchemaModel,
SchemaNamespace,
SchemaNonModel,
ModelOperation,
InternalSchema,
AuthModeStrategy,
ModelAttributes,
isPredicateGroup,
} from '../types';
import {
extractPrimaryKeyFieldNames,
establishRelationAndKeys,
IDENTIFIER_KEY_SEPARATOR,
} from '../util';
import { MutationEvent } from './';
const logger = new Logger('DataStore');
enum GraphQLOperationType {
LIST = 'query',
CREATE = 'mutation',
UPDATE = 'mutation',
DELETE = 'mutation',
GET = 'query',
}
export enum TransformerMutationType {
CREATE = 'Create',
UPDATE = 'Update',
DELETE = 'Delete',
GET = 'Get',
}
const dummyMetadata: ModelInstanceMetadata = {
_version: undefined!,
_lastChangedAt: undefined!,
_deleted: undefined!,
};
const metadataFields = <(keyof ModelInstanceMetadata)[]>(
Object.keys(dummyMetadata)
);
export function getMetadataFields(): ReadonlyArray<string> {
return metadataFields;
}
export function generateSelectionSet(
namespace: SchemaNamespace,
modelDefinition: SchemaModel | SchemaNonModel
): string {
const scalarFields = getScalarFields(modelDefinition);
const nonModelFields = getNonModelFields(namespace, modelDefinition);
const implicitOwnerField = getImplicitOwnerField(
modelDefinition,
scalarFields
);
let scalarAndMetadataFields = Object.values(scalarFields)
.map(({ name }) => name)
.concat(implicitOwnerField)
.concat(nonModelFields);
if (isSchemaModel(modelDefinition)) {
scalarAndMetadataFields = scalarAndMetadataFields
.concat(getMetadataFields())
.concat(getConnectionFields(modelDefinition, namespace));
}
const result = scalarAndMetadataFields.join('\n');
return result;
}
function getImplicitOwnerField(
modelDefinition: SchemaModel | SchemaNonModel,
scalarFields: ModelFields
) {
const ownerFields = getOwnerFields(modelDefinition);
if (!scalarFields.owner && ownerFields.includes('owner')) {
return ['owner'];
}
return [];
}
function getOwnerFields(
modelDefinition: SchemaModel | SchemaNonModel
): string[] {
const ownerFields: string[] = [];
if (isSchemaModelWithAttributes(modelDefinition)) {
modelDefinition.attributes!.forEach(attr => {
if (attr.properties && attr.properties.rules) {
const rule = attr.properties.rules.find(rule => rule.allow === 'owner');
if (rule && rule.ownerField) {
ownerFields.push(rule.ownerField);
}
}
});
}
return ownerFields;
}
function getScalarFields(
modelDefinition: SchemaModel | SchemaNonModel
): ModelFields {
const { fields } = modelDefinition;
const result = Object.values(fields)
.filter(field => {
if (isGraphQLScalarType(field.type) || isEnumFieldType(field.type)) {
return true;
}
return false;
})
.reduce((acc, field) => {
acc[field.name] = field;
return acc;
}, {} as ModelFields);
return result;
}
// Used for generating the selection set for queries and mutations
function getConnectionFields(
modelDefinition: SchemaModel,
namespace: SchemaNamespace
): string[] {
const result: string[] = [];
Object.values(modelDefinition.fields)
.filter(({ association }) => association && Object.keys(association).length)
.forEach(({ name, association }) => {
const { connectionType } = association || {};
switch (connectionType) {
case 'HAS_ONE':
case 'HAS_MANY':
// Intentionally blank
break;
case 'BELONGS_TO':
if (isTargetNameAssociation(association)) {
// New codegen (CPK)
if (association.targetNames && association.targetNames.length > 0) {
// Need to retrieve relations in order to get connected model keys
const [relations] = establishRelationAndKeys(namespace);
const connectedModelName =
modelDefinition.fields[name].type['model'];
const byPkIndex = relations[connectedModelName].indexes.find(
([name]) => name === 'byPk'
);
const keyFields = byPkIndex && byPkIndex[1];
const keyFieldSelectionSet = keyFields?.join(' ');
// We rely on `_deleted` when we process the sync query (e.g. in batchSave in the adapters)
result.push(`${name} { ${keyFieldSelectionSet} _deleted }`);
} else {
// backwards-compatability for schema generated prior to custom primary key support
result.push(`${name} { id _deleted }`);
}
}
break;
default:
throw new Error(`Invalid connection type ${connectionType}`);
}
});
return result;
}
function getNonModelFields(
namespace: SchemaNamespace,
modelDefinition: SchemaModel | SchemaNonModel
): string[] {
const result: string[] = [];
Object.values(modelDefinition.fields).forEach(({ name, type }) => {
if (isNonModelFieldType(type)) {
const typeDefinition = namespace.nonModels![type.nonModel];
const scalarFields = Object.values(getScalarFields(typeDefinition)).map(
({ name }) => name
);
const nested: string[] = [];
Object.values(typeDefinition.fields).forEach(field => {
const { type, name } = field;
if (isNonModelFieldType(type)) {
const typeDefinition = namespace.nonModels![type.nonModel];
nested.push(
`${name} { ${generateSelectionSet(namespace, typeDefinition)} }`
);
}
});
result.push(`${name} { ${scalarFields.join(' ')} ${nested.join(' ')} }`);
}
});
return result;
}
export function getAuthorizationRules(
modelDefinition: SchemaModel
): AuthorizationRule[] {
// Searching for owner authorization on attributes
const authConfig = ([] as ModelAttributes)
.concat(modelDefinition.attributes || [])
.find(attr => attr && attr.type === 'auth');
const { properties: { rules = [] } = {} } = authConfig || {};
const resultRules: AuthorizationRule[] = [];
// Multiple rules can be declared for allow: owner
rules.forEach(rule => {
// setting defaults for backwards compatibility with old cli
const {
identityClaim = 'cognito:username',
ownerField = 'owner',
operations = ['create', 'update', 'delete', 'read'],
provider = 'userPools',
groupClaim = 'cognito:groups',
allow: authStrategy = 'iam',
groups = [],
groupsField = '',
} = rule;
const isReadAuthorized = operations.includes('read');
const isOwnerAuth = authStrategy === 'owner';
if (!isReadAuthorized && !isOwnerAuth) {
return;
}
const authRule: AuthorizationRule = {
identityClaim,
ownerField,
provider,
groupClaim,
authStrategy,
groups,
groupsField,
areSubscriptionsPublic: false,
};
if (isOwnerAuth) {
// look for the subscription level override
// only pay attention to the public level
const modelConfig = ([] as ModelAttributes)
.concat(modelDefinition.attributes || [])
.find(attr => attr && attr.type === 'model');
// find the subscriptions level. ON is default
const { properties: { subscriptions: { level = 'on' } = {} } = {} } =
modelConfig || {};
// treat subscriptions as public for owner auth with unprotected reads
// when `read` is omitted from `operations`
authRule.areSubscriptionsPublic =
!operations.includes('read') || level === 'public';
}
if (isOwnerAuth) {
// owner rules has least priority
resultRules.push(authRule);
return;
}
resultRules.unshift(authRule);
});
return resultRules;
}
export function buildSubscriptionGraphQLOperation(
namespace: SchemaNamespace,
modelDefinition: SchemaModel,
transformerMutationType: TransformerMutationType,
isOwnerAuthorization: boolean,
ownerField: string,
filterArg: boolean = false
): [TransformerMutationType, string, string] {
const selectionSet = generateSelectionSet(namespace, modelDefinition);
const { name: typeName } = modelDefinition;
const opName = `on${transformerMutationType}${typeName}`;
const docArgs: string[] = [];
const opArgs: string[] = [];
if (filterArg) {
docArgs.push(`$filter: ModelSubscription${typeName}FilterInput`);
opArgs.push('filter: $filter');
}
if (isOwnerAuthorization) {
docArgs.push(`$${ownerField}: String!`);
opArgs.push(`${ownerField}: $${ownerField}`);
}
const docStr = docArgs.length ? `(${docArgs.join(',')})` : '';
const opStr = opArgs.length ? `(${opArgs.join(',')})` : '';
return [
transformerMutationType,
opName,
`subscription operation${docStr}{
${opName}${opStr}{
${selectionSet}
}
}`,
];
}
export function buildGraphQLOperation(
namespace: SchemaNamespace,
modelDefinition: SchemaModel,
graphQLOpType: keyof typeof GraphQLOperationType
): [TransformerMutationType, string, string][] {
let selectionSet = generateSelectionSet(namespace, modelDefinition);
const { name: typeName, pluralName: pluralTypeName } = modelDefinition;
let operation: string;
let documentArgs: string;
let operationArgs: string;
let transformerMutationType: TransformerMutationType;
switch (graphQLOpType) {
case 'LIST':
operation = `sync${pluralTypeName}`;
documentArgs = `($limit: Int, $nextToken: String, $lastSync: AWSTimestamp, $filter: Model${typeName}FilterInput)`;
operationArgs =
'(limit: $limit, nextToken: $nextToken, lastSync: $lastSync, filter: $filter)';
selectionSet = `items {
${selectionSet}
}
nextToken
startedAt`;
break;
case 'CREATE':
operation = `create${typeName}`;
documentArgs = `($input: Create${typeName}Input!)`;
operationArgs = '(input: $input)';
transformerMutationType = TransformerMutationType.CREATE;
break;
case 'UPDATE':
operation = `update${typeName}`;
documentArgs = `($input: Update${typeName}Input!, $condition: Model${typeName}ConditionInput)`;
operationArgs = '(input: $input, condition: $condition)';
transformerMutationType = TransformerMutationType.UPDATE;
break;
case 'DELETE':
operation = `delete${typeName}`;
documentArgs = `($input: Delete${typeName}Input!, $condition: Model${typeName}ConditionInput)`;
operationArgs = '(input: $input, condition: $condition)';
transformerMutationType = TransformerMutationType.DELETE;
break;
case 'GET':
operation = `get${typeName}`;
documentArgs = `($id: ID!)`;
operationArgs = '(id: $id)';
transformerMutationType = TransformerMutationType.GET;
break;
default:
throw new Error(`Invalid graphQlOpType ${graphQLOpType}`);
}
return [
[
transformerMutationType!,
operation!,
`${GraphQLOperationType[graphQLOpType]} operation${documentArgs}{
${operation!}${operationArgs}{
${selectionSet}
}
}`,
],
];
}
export function createMutationInstanceFromModelOperation<
T extends PersistentModel
>(
relationships: RelationshipType,
modelDefinition: SchemaModel,
opType: OpType,
model: PersistentModelConstructor<T>,
element: T,
condition: GraphQLCondition,
MutationEventConstructor: PersistentModelConstructor<MutationEvent>,
modelInstanceCreator: ModelInstanceCreator,
id?: string
): MutationEvent {
let operation: TransformerMutationType;
switch (opType) {
case OpType.INSERT:
operation = TransformerMutationType.CREATE;
break;
case OpType.UPDATE:
operation = TransformerMutationType.UPDATE;
break;
case OpType.DELETE:
operation = TransformerMutationType.DELETE;
break;
default:
throw new Error(`Invalid opType ${opType}`);
}
// stringify nested objects of type AWSJSON
// this allows us to return parsed JSON to users (see `castInstanceType()` in datastore.ts),
// but still send the object correctly over the wire
const replacer = (k, v) => {
const isAWSJSON =
k &&
v !== null &&
typeof v === 'object' &&
modelDefinition.fields[k] &&
modelDefinition.fields[k].type === 'AWSJSON';
if (isAWSJSON) {
return JSON.stringify(v);
}
return v;
};
const modelId = getIdentifierValue(modelDefinition, element);
const optionalId = OpType.INSERT && id ? { id } : {};
const mutationEvent = modelInstanceCreator(MutationEventConstructor, {
...optionalId,
data: JSON.stringify(element, replacer),
modelId,
model: model.name,
operation: operation!,
condition: JSON.stringify(condition),
});
return mutationEvent;
}
export function predicateToGraphQLCondition(
predicate: PredicatesGroup<any>,
modelDefinition: SchemaModel
): GraphQLCondition {
const result = {};
if (!predicate || !Array.isArray(predicate.predicates)) {
return result;
}
// This is compatible with how the GQL Transform currently generates the Condition Input,
// i.e. any PK and SK fields are omitted and can't be used as conditions.
// However, I think this limits usability.
// What if we want to delete all records where SK > some value
// Or all records where PK = some value but SKs are different values
// TODO: if the Transform gets updated we'll need to modify this logic to only omit
// key fields from the predicate/condition when ALL of the keyFields are present and using `eq` operators
const keyFields = extractPrimaryKeyFieldNames(modelDefinition);
return predicateToGraphQLFilter(predicate, keyFields) as GraphQLCondition;
}
/**
* @param predicatesGroup - Predicate Group
@returns GQL Filter Expression from Predicate Group
@remarks Flattens redundant list predicates
@example
```js
{ and:[{ and:[{ username: { eq: 'bob' }}] }] }
```
Becomes
```js
{ and:[{ username: { eq: 'bob' }}] }
```
*/
export function predicateToGraphQLFilter(
predicatesGroup: PredicatesGroup<any>,
fieldsToOmit: string[] = [],
root = true
): GraphQLFilter {
const result: GraphQLFilter = {};
if (!predicatesGroup || !Array.isArray(predicatesGroup.predicates)) {
return result;
}
const { type, predicates } = predicatesGroup;
const isList = type === 'and' || type === 'or';
result[type] = isList ? [] : {};
const children: GraphQLFilter[] = [];
predicates.forEach(predicate => {
if (isPredicateObj(predicate)) {
const { field, operator, operand } = predicate;
if (fieldsToOmit.includes(field as string)) return;
const gqlField: GraphQLField = {
[field]: { [operator]: operand },
};
children.push(gqlField);
return;
}
const child = predicateToGraphQLFilter(predicate, fieldsToOmit, false);
if (Object.keys(child).length > 0) {
children.push(child);
}
});
// flatten redundant list predicates
if (children.length === 1) {
const [child] = children;
if (
// any nested list node
(isList && !root) ||
// root list node where the only child is also a list node
(isList && root && ('and' in child || 'or' in child))
) {
delete result[type];
Object.assign(result, child);
return result;
}
}
children.forEach(child => {
if (isList) {
result[type].push(child);
} else {
result[type] = child;
}
});
if (isList) {
if (result[type].length === 0) return {};
} else {
if (Object.keys(result[type]).length === 0) return {};
}
return result;
}
/**
*
* @param group - selective sync predicate group
* @returns set of distinct field names in the filter group
*/
export function filterFields(group?: PredicatesGroup<any>): Set<string> {
const fields = new Set<string>();
if (!group || !Array.isArray(group.predicates)) return fields;
const { predicates } = group;
const stack = [...predicates];
while (stack.length > 0) {
const current = stack.pop();
if (isPredicateObj(current)) {
fields.add(current.field as string);
} else if (isPredicateGroup(current)) {
stack.push(...current.predicates);
}
}
return fields;
}
/**
*
* @param modelDefinition
* @returns set of field names used with dynamic auth modes configured for the provided model definition
*/
export function dynamicAuthFields(modelDefinition: SchemaModel): Set<string> {
const rules = getAuthorizationRules(modelDefinition);
const fields = new Set<string>();
for (const rule of rules) {
if (rule.groupsField && !rule.groups.length) {
// dynamic group rule will have no values in `rule.groups`
fields.add((rule as AuthorizationRule).groupsField);
} else if (rule.ownerField) {
fields.add(rule.ownerField);
}
}
return fields;
}
/**
*
* @param group - selective sync predicate group
* @returns the total number of OR'd predicates in the filter group
*
* @example returns 2
* ```js
* { type: "or", predicates: [
* { field: "username", operator: "beginsWith", operand: "a" },
* { field: "title", operator: "contains", operand: "abc" },
* ]}
* ```
*/
export function countFilterCombinations(group?: PredicatesGroup<any>): number {
if (!group || !Array.isArray(group.predicates)) return 0;
let count = 0;
const stack: (PredicatesGroup<any> | PredicateObject<any>)[] = [group];
while (stack.length > 0) {
const current = stack.pop();
if (isPredicateGroup(current)) {
const { predicates, type } = current;
// ignore length = 1; groups with 1 predicate will get flattened when converted to gqlFilter
if (type === 'or' && predicates.length > 1) {
count += predicates.length;
}
stack.push(...predicates);
}
}
// if we didn't encounter any OR groups, default to 1
return count || 1;
}
/**
*
* @param group - selective sync predicate group
* @returns name of repeated field | null
*
* @example returns "username"
* ```js
* { type: "and", predicates: [
* { field: "username", operator: "beginsWith", operand: "a" },
* { field: "username", operator: "contains", operand: "abc" },
* ] }
* ```
*/
export function repeatedFieldInGroup(
group?: PredicatesGroup<any>
): string | null {
if (!group || !Array.isArray(group.predicates)) return null;
// convert to filter in order to flatten redundant groups
const gqlFilter = predicateToGraphQLFilter(group);
const stack: GraphQLFilter[] = [gqlFilter];
const hasGroupRepeatedFields = (fields: GraphQLFilter[]): string | null => {
const seen = {};
for (const f of fields) {
const [fieldName] = Object.keys(f);
if (seen[fieldName]) {
return fieldName;
}
seen[fieldName] = true;
}
return null;
};
while (stack.length > 0) {
const current = stack.pop();
const [key] = Object.keys(current!);
const values = current![key];
if (!Array.isArray(values)) {
return null;
}
// field value will be single object
const predicateObjects = values.filter(
v => !Array.isArray(Object.values(v)[0])
);
// group value will be an array
const predicateGroups = values.filter(v =>
Array.isArray(Object.values(v)[0])
);
if (key === 'and') {
const repeatedField = hasGroupRepeatedFields(predicateObjects);
if (repeatedField) {
return repeatedField;
}
}
stack.push(...predicateGroups);
}
return null;
}
export enum RTFError {
UnknownField,
MaxAttributes,
MaxCombinations,
RepeatedFieldname,
NotGroup,
FieldNotInType,
}
export function generateRTFRemediation(
errorType: RTFError,
modelDefinition: SchemaModel,
predicatesGroup: PredicatesGroup<any> | undefined
): string {
const selSyncFields = filterFields(predicatesGroup);
const selSyncFieldStr = [...selSyncFields].join(', ');
const dynamicAuthModeFields = dynamicAuthFields(modelDefinition);
const dynamicAuthFieldsStr = [...dynamicAuthModeFields].join(', ');
const filterCombinations = countFilterCombinations(predicatesGroup);
const repeatedField = repeatedFieldInGroup(predicatesGroup);
switch (errorType) {
case RTFError.UnknownField:
return (
`Your API was generated with an older version of the CLI that doesn't support backend subscription filtering.` +
'To enable backend subscription filtering, upgrade your Amplify CLI to the latest version and push your app by running `amplify upgrade` followed by `amplify push`'
);
case RTFError.MaxAttributes: {
let message = `Your selective sync expression for ${modelDefinition.name} contains ${selSyncFields.size} different model fields: ${selSyncFieldStr}.\n\n`;
if (dynamicAuthModeFields.size > 0) {
message +=
`Note: the number of fields you can use with selective sync is affected by @auth rules configured on the model.\n\n` +
`Dynamic auth modes, such as owner auth and dynamic group auth each utilize 1 field.\n` +
`You currently have ${dynamicAuthModeFields.size} dynamic auth mode(s) configured on this model: ${dynamicAuthFieldsStr}.`;
}
return message;
}
case RTFError.MaxCombinations: {
let message = `Your selective sync expression for ${modelDefinition.name} contains ${filterCombinations} field combinations (total number of predicates in an OR expression).\n\n`;
if (dynamicAuthModeFields.size > 0) {
message +=
`Note: the number of fields you can use with selective sync is affected by @auth rules configured on the model.\n\n` +
`Dynamic auth modes, such as owner auth and dynamic group auth factor in to the number of combinations you're using.\n` +
`You currently have ${dynamicAuthModeFields.size} dynamic auth mode(s) configured on this model: ${dynamicAuthFieldsStr}.`;
}
return message;
}
case RTFError.RepeatedFieldname:
return `Your selective sync expression for ${modelDefinition.name} contains multiple entries for ${repeatedField} in the same AND group.`;
case RTFError.NotGroup:
return (
`Your selective sync expression for ${modelDefinition.name} uses a \`not\` group. If you'd like to filter subscriptions in the backend, ` +
`rewrite your expression using \`ne\` or \`notContains\` operators.`
);
case RTFError.FieldNotInType:
// no remediation instructions. We'll surface the message directly
return '';
}
}
export function getUserGroupsFromToken(
token: { [field: string]: any },
rule: AuthorizationRule
): string[] {
// validate token against groupClaim
let userGroups: string[] | string = token[rule.groupClaim] || [];
if (typeof userGroups === 'string') {
let parsedGroups;
try {
parsedGroups = JSON.parse(userGroups);
} catch (e) {
parsedGroups = userGroups;
}
userGroups = [].concat(parsedGroups);
}
return userGroups;
}
export async function getModelAuthModes({
authModeStrategy,
defaultAuthMode,
modelName,
schema,
}: {
authModeStrategy: AuthModeStrategy;
defaultAuthMode: GRAPHQL_AUTH_MODE;
modelName: string;
schema: InternalSchema;
}): Promise<{
[key in ModelOperation]: GRAPHQL_AUTH_MODE[];
}> {
const operations = Object.values(ModelOperation);
const modelAuthModes: {
[key in ModelOperation]: GRAPHQL_AUTH_MODE[];
} = {
CREATE: [],
READ: [],
UPDATE: [],
DELETE: [],
};
try {
await Promise.all(
operations.map(async operation => {
const authModes = await authModeStrategy({
schema,
modelName,
operation,
});
if (typeof authModes === 'string') {
modelAuthModes[operation] = [authModes];
} else if (Array.isArray(authModes) && authModes.length) {
modelAuthModes[operation] = authModes;
} else {
// Use default auth mode if nothing is returned from authModeStrategy
modelAuthModes[operation] = [defaultAuthMode];
}
})
);
} catch (error) {
logger.debug(`Error getting auth modes for model: ${modelName}`, error);
}
return modelAuthModes;
}
export function getForbiddenError(error) {
const forbiddenErrorMessages = [
'Request failed with status code 401',
'Request failed with status code 403',
];
let forbiddenError;
if (error && error.errors) {
forbiddenError = (error.errors as [any]).find(err =>
forbiddenErrorMessages.includes(err.message)
);
} else if (error && error.message) {
forbiddenError = error;
}
if (forbiddenError) {
return forbiddenError.message;
}
return null;
}
export function getClientSideAuthError(error) {
const clientSideAuthErrors = Object.values(GraphQLAuthError);
const clientSideError =
error &&
error.message &&
clientSideAuthErrors.find(clientError =>
error.message.includes(clientError)
);
return clientSideError || null;
}
export async function getTokenForCustomAuth(
authMode: GRAPHQL_AUTH_MODE,
amplifyConfig: Record<string, any> = {}
): Promise<string | undefined> {
if (authMode === GRAPHQL_AUTH_MODE.AWS_LAMBDA) {
const {
authProviders: { functionAuthProvider } = { functionAuthProvider: null },
} = amplifyConfig;
if (functionAuthProvider && typeof functionAuthProvider === 'function') {
try {
const { token } = await functionAuthProvider();
return token;
} catch (error) {
throw new Error(
`Error retrieving token from \`functionAuthProvider\`: ${error}`
);
}
} else {
// TODO: add docs link once available
throw new Error(
`You must provide a \`functionAuthProvider\` function to \`DataStore.configure\` when using ${GRAPHQL_AUTH_MODE.AWS_LAMBDA}`
);
}
}
}
// Util that takes a modelDefinition and model and returns either the id value(s) or the custom primary key value(s)
export function getIdentifierValue(
modelDefinition: SchemaModel,
model: ModelInstanceMetadata | PersistentModel
): string {
const pkFieldNames = extractPrimaryKeyFieldNames(modelDefinition);
const idOrPk = pkFieldNames.map(f => model[f]).join(IDENTIFIER_KEY_SEPARATOR);
return idOrPk;
}