Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.openmetadata.schema.type.CustomPropertyConfig;
import org.openmetadata.schema.type.customProperties.EnumConfig;
import org.openmetadata.sdk.client.OpenMetadataClient;
import org.openmetadata.sdk.exceptions.InvalidRequestException;
import org.openmetadata.sdk.network.HttpMethod;

/**
Expand Down Expand Up @@ -324,6 +325,164 @@ void test_typeWithInvalidName_fails(TestNamespace ns) {
}
}

@Test
void test_customPropertyNameAllowedCharacters_succeeds(TestNamespace ns) throws Exception {
OpenMetadataClient client = SdkClients.adminClient();
UUID tableTypeId = TABLE_ENTITY_TYPE.getId();
String prefix = ns.prefix("safe");

String[] allowedNames = {
prefix + "Plain",
prefix + "_underscore",
prefix + "-hyphen",
prefix + ".dot",
prefix + "with space",
prefix + "with/slash",
prefix + "with&amp",
prefix + "with%percent",
prefix + "with#hash",
prefix + "with@at",
prefix + "with!bang",
prefix + "with,comma",
prefix + "with;semi",
prefix + "with=eq",
prefix + "with|pipe",
prefix + "with'quote",
prefix + "with(lparen",
prefix + "with)rparen",
prefix + "with<lt",
prefix + "with>gt",
prefix + "with[lbrack",
prefix + "with]rbrack",
prefix + "with{lbrace",
prefix + "with}rbrace",
prefix + "with+plus",
prefix + "with?question",
prefix + "with*asterisk",
prefix + "with~tilde",
prefix + "with`backtick",
prefix + "withMatched(pair)And<more>",
prefix + "withDigits123",
};

for (String name : allowedNames) {
CustomProperty property = new CustomProperty();
property.setName(name);
property.setDescription("Allowed-charset test for custom property name");
property.setPropertyType(STRING_TYPE.getEntityReference());

Type updatedType = addCustomProperty(client, tableTypeId, property);
assertNotNull(updatedType, "Allowed name '" + name + "' must be accepted");

boolean present =
updatedType.getCustomProperties().stream().anyMatch(cp -> name.equals(cp.getName()));
assertTrue(present, "Custom property '" + name + "' should be saved on the type");
}
}

@Test
void test_customPropertyNameDisallowedCharacters_fails(TestNamespace ns) {
OpenMetadataClient client = SdkClients.adminClient();
UUID tableTypeId = TABLE_ENTITY_TYPE.getId();
String prefix = ns.prefix("bad");

String[] disallowedNames = {
prefix + "with\"dquote",
prefix + "with:colon",
prefix + "with^caret",
prefix + "with$dollar",
prefix + "with\\backslash",
};

for (String name : disallowedNames) {
CustomProperty property = new CustomProperty();
property.setName(name);
property.setDescription("Disallowed-charset test for custom property name");
property.setPropertyType(STRING_TYPE.getEntityReference());

assertThrows(
InvalidRequestException.class,
() -> addCustomProperty(client, tableTypeId, property),
"Custom property name '" + name + "' should be rejected with HTTP 400");
}
}

@Test
void test_customPropertyNameMustStartWithAlphanumeric_fails(TestNamespace ns) {
OpenMetadataClient client = SdkClients.adminClient();
UUID tableTypeId = TABLE_ENTITY_TYPE.getId();
String prefix = ns.prefix("lead");

String[] invalidLeads = {
"_" + prefix, "-" + prefix, "." + prefix, " " + prefix, "(" + prefix, "<" + prefix,
};

for (String name : invalidLeads) {
CustomProperty property = new CustomProperty();
property.setName(name);
property.setDescription("Leading-character validation");
property.setPropertyType(STRING_TYPE.getEntityReference());

assertThrows(
InvalidRequestException.class,
() -> addCustomProperty(client, tableTypeId, property),
"Custom property name '" + name + "' must start with alphanumeric (HTTP 400 expected)");
}
}

@Test
void test_customPropertyNameTooLong_fails(TestNamespace ns) {
OpenMetadataClient client = SdkClients.adminClient();
UUID tableTypeId = TABLE_ENTITY_TYPE.getId();

StringBuilder longName = new StringBuilder(ns.prefix("long"));
while (longName.length() <= 256) {
longName.append('a');
}

CustomProperty property = new CustomProperty();
property.setName(longName.toString());
property.setDescription("Length validation");
property.setPropertyType(STRING_TYPE.getEntityReference());

assertThrows(
InvalidRequestException.class,
() -> addCustomProperty(client, tableTypeId, property),
"Custom property name longer than 256 characters should be rejected with HTTP 400");
}

@Test
void test_customPropertyNameUnbalancedBrackets_succeeds(TestNamespace ns) throws Exception {
OpenMetadataClient client = SdkClients.adminClient();
UUID tableTypeId = TABLE_ENTITY_TYPE.getId();
String prefix = ns.prefix("bracket");

String[] unbalancedNames = {
prefix + "openParen(",
prefix + "closeParen)",
prefix + "openLt<",
prefix + "closeGt>",
prefix + "openLbrack[",
prefix + "closeRbrack]",
prefix + "openLbrace{",
prefix + "closeRbrace}",
};

for (String name : unbalancedNames) {
CustomProperty property = new CustomProperty();
property.setName(name);
property.setDescription("Unbalanced-bracket validation");
property.setPropertyType(STRING_TYPE.getEntityReference());

Type updatedType = addCustomProperty(client, tableTypeId, property);
assertNotNull(updatedType);

boolean present =
updatedType.getCustomProperties().stream().anyMatch(cp -> name.equals(cp.getName()));
assertTrue(present, "Unbalanced bracket name '" + name + "' should be saved");
}
}

@Test
void test_getEntityTypeFields() throws Exception {
OpenMetadataClient client = SdkClients.adminClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@
"maxLength": 256,
"pattern": "^((?!::).)*$"
},
"customPropertyName": {
"description": "Name of a custom property. Allowed characters: alphanumeric, _ - . / & % # @ ! , ; = | ' + ? * ~ ` space ( ) < > [ ] { }. Must start with an alphanumeric character. Disallowed characters: \" : ^ $ \\",
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[A-Za-z0-9][A-Za-z0-9 _\\-.,;/&%#@!'(){}\\[\\]<>|=+?*~`]*$"
},
Comment on lines +129 to +135
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new customPropertyName regex here allows additional characters (+ ? * ~ �backtick) and also implicitly disallows backslash (\\), but the PR description and some generated docs elsewhere mention only four disallowed characters and omit these allowed ones. Please either align the pattern with the intended charset, or update the PR/docs to reflect the actual rule (including whether \\ is intentionally disallowed).

Copilot uses AI. Check for mistakes.
"testCaseEntityName": {
"description": "Name that identifies a test definition and test case.",
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
},
"properties": {
"name": {
"description": "Name of the entity property. Note a property name must be unique for an entity. Property name must follow camelCase naming adopted by openMetadata - must start with lower case with no space, underscore, or dots.",
"$ref": "../type/basic.json#/definitions/entityName"
"description": "Name of the entity property. Must be unique for an entity. Allowed characters: alphanumeric, _ - . / & % # @ ! , ; = | ' + ? * ~ ` space ( ) < > [ ] { }. Must start with an alphanumeric character. Disallowed: \" : ^ $ \\.",
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name.description text ends with \\. which renders as \. (backslash + dot) and is confusing/inaccurate (dot is allowed by the pattern). If the intent is to list \ as a disallowed character, update the description to show a literal backslash without the trailing . and keep the allowed/disallowed lists consistent with basic.json#/definitions/customPropertyName.

Suggested change
"description": "Name of the entity property. Must be unique for an entity. Allowed characters: alphanumeric, _ - . / & % # @ ! , ; = | ' + ? * ~ ` space ( ) < > [ ] { }. Must start with an alphanumeric character. Disallowed: \" : ^ $ \\.",
"description": "Name of the entity property. Must be unique for an entity. Allowed characters: alphanumeric, _ - . / & % # @ ! , ; = | ' + ? * ~ ` space ( ) < > [ ] { }. Must start with an alphanumeric character. Disallowed: \" : ^ $ \\",

Copilot uses AI. Check for mistakes.
"$ref": "../type/basic.json#/definitions/customPropertyName"
Comment on lines +52 to +53
},
"displayName": {
"description": "Display Name for the custom property.Must be unique for an entity.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,15 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
};

export const CUSTOM_PROPERTY_NAME_VALIDATION_ERROR =
"Name must not contain '::'.";
'Name must start with a letter or number. Invalid characters: " : ^ $ \\';

export const CUSTOM_PROPERTY_INVALID_NAMES = {
STARTS_WITH_SPECIAL_CHAR: '_invalidName',
DISALLOWED_COLON: 'name:with:colon',
DISALLOWED_DOLLAR: 'name$invalid',
DISALLOWED_CARET: 'name^invalid',
DISALLOWED_QUOTE: 'name"invalid',
DISALLOWED_BACKSLASH: String.raw`name\invalid`,
};

export const NAME_SUFFIX = ".!@#%`&*()_-=+{}[]~|;'<>,.?/";
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@
*/

import { APIRequestContext, expect, test } from '@playwright/test';
import { CUSTOM_PROPERTIES_ENTITIES } from '../../constant/customProperty';
import {
INVALID_NAMES,
NAME_MAX_LENGTH_VALIDATION_ERROR,
} from '../../constant/common';
import {
CUSTOM_PROPERTIES_ENTITIES,
CUSTOM_PROPERTY_INVALID_NAMES,
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR,
NAME_SUFFIX,
} from '../../constant/customProperty';
import {
CP_BASE_VALUES,
CP_PARTIAL_SEARCH_VALUES,
Expand Down Expand Up @@ -267,11 +276,15 @@ ALL_ENTITIES.forEach(({ key, makeInstance }) => {
createdCPData: [],
};
const propertyNames: Record<string, string> = {};
const dashboardSearchPropertyName = `cp-${uuid()}-${entity.name}`;
const dashboardSearchPropertyName = `cp-${uuid()}-${
entity.name
}${NAME_SUFFIX}`;
const dashboardPropertyValue = `EXECUTIVE_DASHBOARD_${uuid()}`;

// Pipeline-specific state
const pipelineSearchPropertyName = `cp-${uuid()}-${entity.name}`;
const pipelineSearchPropertyName = `cp-${uuid()}-${
entity.name
}${NAME_SUFFIX}`;
const pipelinePropertyValue = `ETL_PRODUCTION_${uuid()}`;

test.beforeAll(async ({ browser }) => {
Expand Down Expand Up @@ -352,7 +365,7 @@ ALL_ENTITIES.forEach(({ key, makeInstance }) => {
BASIC_PROPERTIES.forEach((property) => {
test(property, async ({ page }) => {
test.slow();
const propertyName = `cp-${uuid()}-${entity.name}`;
const propertyName = `cp-${uuid()}-${entity.name}${NAME_SUFFIX}`;

await settingClick(
page,
Expand Down Expand Up @@ -387,7 +400,7 @@ ALL_ENTITIES.forEach(({ key, makeInstance }) => {
CONFIG_PROPERTIES.forEach((propertyConfig) => {
test(propertyConfig.name, async ({ page }) => {
test.slow();
const propertyName = `cp-${uuid()}-${entity.name}`;
const propertyName = `cp-${uuid()}-${entity.name}${NAME_SUFFIX}`;

await settingClick(
page,
Expand Down Expand Up @@ -3528,3 +3541,102 @@ ALL_ENTITIES.forEach(({ key, makeInstance }) => {
}
});
});

test.describe('Custom property name validation', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });

test.beforeEach(async ({ page }) => {
await redirectToHomePage(page);
await settingClick(page, GlobalSettingOptions.TABLES, true);
await page.click('[data-testid="add-field-button"]');
});

const nameInput = '[data-testid="name"] input';
const nameError = '#name_help';

test('should show error when name starts with a non-alphanumeric character', async ({
page,
}) => {
await page.fill(
nameInput,
CUSTOM_PROPERTY_INVALID_NAMES.STARTS_WITH_SPECIAL_CHAR
);

await expect(page.locator(nameError)).toContainText(
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR
);
});

test('should show error when name contains a colon', async ({ page }) => {
await page.fill(nameInput, CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_COLON);

await expect(page.locator(nameError)).toContainText(
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR
);
});

test('should show error when name contains a dollar sign', async ({
page,
}) => {
await page.fill(nameInput, CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_DOLLAR);

await expect(page.locator(nameError)).toContainText(
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR
);
});

test('should show error when name contains a caret', async ({ page }) => {
await page.fill(nameInput, CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_CARET);

await expect(page.locator(nameError)).toContainText(
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR
);
});

test('should show error when name contains a double quote', async ({
page,
}) => {
await page.fill(nameInput, CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_QUOTE);

await expect(page.locator(nameError)).toContainText(
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR
);
});

test('should show error when name contains a backslash', async ({ page }) => {
await page.fill(
nameInput,
CUSTOM_PROPERTY_INVALID_NAMES.DISALLOWED_BACKSLASH
);

await expect(page.locator(nameError)).toContainText(
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR
);
});

test('should accept a valid name starting with a letter', async ({
page,
}) => {
await page.fill(nameInput, 'validName_123');

await expect(page.locator(nameError)).not.toBeVisible();
});

test('should accept a valid name with allowed special characters', async ({
page,
}) => {
await page.fill(nameInput, "valid Name_-./&%#@!,;=|'()<>[]{}");

Comment on lines +3625 to +3629
await expect(page.locator(nameError)).not.toBeVisible();
});

test('should show error when name exceeds 128 characters', async ({
page,
}) => {
await page.fill(nameInput, INVALID_NAMES.MAX_LENGTH);

await expect(page.locator(nameError)).toContainText(
NAME_MAX_LENGTH_VALIDATION_ERROR
);
Comment on lines +3633 to +3640
Comment on lines +3633 to +3640
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,7 @@ export const selectOption = async (
// Use .first() to handle multiple matches (acceptable when scoped to visible dropdown)
const optionLocator = page
.locator('.ant-select-dropdown:visible')
.locator('.ant-select-item-option')
.filter({
hasText: new RegExp(
`^${optionTitle.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`
),
})
.locator(`[title="${optionTitle}"]`)
.first();
await expect(optionLocator).toBeVisible();

Expand Down
Loading
Loading