From f9a4cc25daa64d4ea168c5d850308118f8edabcd Mon Sep 17 00:00:00 2001 From: michael gong Date: Fri, 6 Mar 2026 15:52:31 +1100 Subject: [PATCH 1/2] Case insensitive search fix: add case-insensitive search support for keyword search Add a case-insensitive toggle to the keyword search sidebar that works across all three query engines (Gremlin, OpenCypher, SPARQL). Gremlin: Uses regex with (?i) flag. Escapes the `$` anchor as `\$` to prevent Groovy string interpolation errors. Regex metacharacters in search terms are escaped to avoid injection. OpenCypher: Wraps attribute values and search terms in toLower() for case-insensitive comparison. SPARQL: Uses regex() with "i" flag for partial matches and lcase() for exact matches. The toggle does not apply to ID-based searches since IDs are system-generated and case-sensitivity is not meaningful. --- .../keywordSearchTemplate.test.ts | 81 ++++++++++++++ .../keywordSearch/keywordSearchTemplate.ts | 13 +++ .../keywordSearchTemplate.test.ts | 94 ++++++++++++++++ .../keywordSearch/keywordSearchTemplate.ts | 7 ++ .../keywordSearchTemplate.test.ts | 102 ++++++++++++++++++ .../keywordSearch/keywordSearchTemplate.ts | 21 +++- .../src/connector/sparql/sparqlExplorer.ts | 1 + .../src/connector/sparql/types.ts | 5 +- .../src/connector/useGEFetchTypes.ts | 4 + .../SearchSidebar/FilterSearchTabContent.tsx | 11 ++ .../SearchSidebar/useKeywordSearch.test.ts | 27 +++++ .../modules/SearchSidebar/useKeywordSearch.ts | 8 +- .../SearchSidebar/useKeywordSearchQuery.ts | 3 + 13 files changed, 369 insertions(+), 8 deletions(-) diff --git a/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.test.ts b/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.test.ts index c71f1d7d6..33a0c0363 100644 --- a/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.test.ts +++ b/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.test.ts @@ -163,4 +163,85 @@ describe("Gremlin > keywordSearchTemplate", () => { ), ); }); + + it("Should return a case-insensitive template for partial match", () => { + const template = keywordSearchTemplate({ + searchTerm: "JFK", + searchByAttributes: ["city", "code"], + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize( + 'g.V().or(has("city",regex("(?i).*JFK.*")),has("code",regex("(?i).*JFK.*")))', + ), + ); + }); + + it("Should return a case-insensitive template for exact match", () => { + const template = keywordSearchTemplate({ + searchTerm: "JFK", + searchByAttributes: ["city", "code"], + exactMatch: true, + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize( + 'g.V().or(has("city",regex("(?i)^JFK\\$")),has("code",regex("(?i)^JFK\\$")))', + ), + ); + }); + + it("Should not apply case-insensitive to ID searches", () => { + const template = keywordSearchTemplate({ + vertexTypes: ["airport"], + searchTerm: "JFK", + exactMatch: false, + caseInsensitive: true, + searchByAttributes: [SEARCH_TOKENS.NODE_ID], + }); + + expect(normalize(template)).toBe( + normalize('g.V().hasLabel("airport").or(has(id,containing("JFK")))'), + ); + }); + + it("Should not apply regex when caseInsensitive is false", () => { + const template = keywordSearchTemplate({ + searchTerm: "JFK", + searchByAttributes: ["city"], + exactMatch: false, + caseInsensitive: false, + }); + + expect(normalize(template)).toBe( + normalize('g.V().or(has("city",containing("JFK")))'), + ); + }); + + it("Should escape regex metacharacters in case-insensitive partial match", () => { + const template = keywordSearchTemplate({ + searchTerm: "test.com", + searchByAttributes: ["url"], + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize('g.V().or(has("url",regex("(?i).*test\\\\.com.*")))'), + ); + }); + + it("Should escape regex metacharacters in case-insensitive exact match", () => { + const template = keywordSearchTemplate({ + searchTerm: "test.com", + searchByAttributes: ["url"], + exactMatch: true, + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize('g.V().or(has("url",regex("(?i)^test\\\\.com\\$")))'), + ); + }); }); diff --git a/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.ts b/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.ts index 337cd6166..6b2b4af0d 100644 --- a/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.ts +++ b/packages/graph-explorer/src/connector/gremlin/keywordSearch/keywordSearchTemplate.ts @@ -4,6 +4,10 @@ import type { KeywordSearchRequest } from "@/connector"; import { escapeString, SEARCH_TOKENS } from "@/utils"; +function escapeRegexForGremlin(text: string): string { + return text.replace(/[.*+?^${}()|[\]]/g, "\\\\$&"); +} + /** * @example * searchTerm = "JFK" @@ -28,6 +32,7 @@ export default function keywordSearchTemplate({ limit, offset = 0, exactMatch = false, + caseInsensitive = false, }: KeywordSearchRequest): string { let template = "g.V()"; @@ -55,6 +60,14 @@ export default function keywordSearchTemplate({ } return `has(id,containing("${escapedSearchTerm}"))`; } + + if (caseInsensitive) { + const regexTerm = escapeRegexForGremlin(escapedSearchTerm); + return exactMatch === true + ? `has("${attr}",regex("(?i)^${regexTerm}\\$"))` + : `has("${attr}",regex("(?i).*${regexTerm}.*"))`; + } + if (exactMatch === true) { return `has("${attr}","${escapedSearchTerm}")`; } diff --git a/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.test.ts b/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.test.ts index de50a397a..b25219d17 100644 --- a/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.test.ts +++ b/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.test.ts @@ -258,4 +258,98 @@ describe("OpenCypher > keywordSearchTemplate", () => { `), ); }); + + it("Should return a case-insensitive template for searched attributes containing the search term", () => { + const template = keywordSearchTemplate({ + vertexTypes: ["airport"], + searchTerm: "JFK", + searchByAttributes: ["city", "code"], + exactMatch: false, + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize(` + MATCH (v:\`airport\`) + WHERE (toLower(toString(v.city)) CONTAINS toLower("JFK") OR toLower(toString(v.code)) CONTAINS toLower("JFK")) + RETURN v AS object + `), + ); + }); + + it("Should return a case-insensitive template for searched attributes exactly matching the search term", () => { + const template = keywordSearchTemplate({ + vertexTypes: ["airport"], + searchTerm: "JFK", + searchByAttributes: ["city", "code"], + exactMatch: true, + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize(` + MATCH (v:\`airport\`) + WHERE (toLower(toString(v.city)) = toLower("JFK") OR toLower(toString(v.code)) = toLower("JFK")) + RETURN v AS object + `), + ); + }); + + it("Should not apply case-insensitive to ID searches", () => { + const template = keywordSearchTemplate({ + vertexTypes: ["airport"], + searchTerm: "JFK", + exactMatch: false, + caseInsensitive: true, + searchByAttributes: [SEARCH_TOKENS.NODE_ID], + }); + + expect(normalize(template)).toBe( + normalize(` + MATCH (v:\`airport\`) + WHERE (toString(id(v)) CONTAINS "JFK") + RETURN v AS object + `), + ); + }); + + it("Should not apply toLower when caseInsensitive is false", () => { + const template = keywordSearchTemplate({ + vertexTypes: ["airport"], + searchTerm: "JFK", + searchByAttributes: ["city"], + exactMatch: false, + caseInsensitive: false, + }); + + expect(normalize(template)).toBe( + normalize(` + MATCH (v:\`airport\`) + WHERE (v.city CONTAINS "JFK") + RETURN v AS object + `), + ); + }); + + it("Should return a case-insensitive template with multiple vertex types and all search parameters", () => { + const template = keywordSearchTemplate({ + vertexTypes: ["airport", "country"], + searchTerm: "JFK", + exactMatch: false, + caseInsensitive: true, + searchByAttributes: ["city", "code", SEARCH_TOKENS.ALL_ATTRIBUTES], + limit: 50, + offset: 25, + }); + + expect(normalize(template)).toBe( + normalize(` + MATCH (v) + WHERE (v:\`airport\` OR v:\`country\`) + AND (toString(id(v)) CONTAINS "JFK" OR toLower(toString(v.city)) CONTAINS toLower("JFK") OR toLower(toString(v.code)) CONTAINS toLower("JFK")) + RETURN v AS object + SKIP 25 LIMIT 50 + `), + ); + }); }); diff --git a/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.ts b/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.ts index 1f4f6c8fa..5173f4a5b 100644 --- a/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.ts +++ b/packages/graph-explorer/src/connector/openCypher/keywordSearch/keywordSearchTemplate.ts @@ -26,6 +26,7 @@ const keywordSearchTemplate = ({ limit, offset, exactMatch, + caseInsensitive, }: KeywordSearchRequest): string => { // Check if we're searching for nodes with no type by checking that the MISSING_TYPE is the only type defined const isMissingTypeSearch = @@ -62,6 +63,12 @@ const keywordSearchTemplate = ({ : `toString(id(v)) CONTAINS "${escapeString(searchTerm)}"`; } + if (caseInsensitive) { + return exactMatch === true + ? `toLower(toString(v.${attr})) = toLower("${escapeString(searchTerm)}")` + : `toLower(toString(v.${attr})) CONTAINS toLower("${escapeString(searchTerm)}")`; + } + return exactMatch === true ? `v.${attr} = "${escapeString(searchTerm)}"` : `v.${attr} CONTAINS "${escapeString(searchTerm)}"`; diff --git a/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.test.ts b/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.test.ts index 6e4cecc2c..aed5e3a20 100644 --- a/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.test.ts +++ b/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.test.ts @@ -305,4 +305,106 @@ describe("SPARQL > keywordSearchTemplate", () => { `), ); }); + + it("Should return a case-insensitive template for partial match", () => { + const template = keywordSearchTemplate({ + subjectClasses: ["air:airport"], + searchTerm: "JFK", + predicates: ["air:city"], + exactMatch: false, + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize(` + SELECT DISTINCT ?subject ?predicate ?object + WHERE { + { + # This sub-query will find any matching instances to the given filters and limit the results + SELECT DISTINCT ?subject + WHERE { + ?subject ?pValue ?value . + OPTIONAL { ?subject a ?class } . + FILTER (?pValue IN ()) + FILTER (?class IN ()) + FILTER (regex(str(?value), "JFK", "i")) + } + } + { + # Values and types + ?subject ?predicate ?object + FILTER(isLiteral(?object) || ?predicate = ) + } + } + `), + ); + }); + + it("Should return a case-insensitive template for exact match", () => { + const template = keywordSearchTemplate({ + subjectClasses: ["air:airport"], + searchTerm: "JFK", + predicates: ["air:city"], + exactMatch: true, + caseInsensitive: true, + }); + + expect(normalize(template)).toBe( + normalize(` + SELECT DISTINCT ?subject ?predicate ?object + WHERE { + { + # This sub-query will find any matching instances to the given filters and limit the results + SELECT DISTINCT ?subject + WHERE { + ?subject ?pValue ?value . + OPTIONAL { ?subject a ?class } . + FILTER (?pValue IN ()) + FILTER (?class IN ()) + FILTER (lcase(str(?value)) = lcase("JFK")) + } + } + { + # Values and types + ?subject ?predicate ?object + FILTER(isLiteral(?object) || ?predicate = ) + } + } + `), + ); + }); + + it("Should keep partial match case-insensitive even when caseInsensitive is false", () => { + const template = keywordSearchTemplate({ + subjectClasses: ["air:airport"], + searchTerm: "JFK", + predicates: ["air:city"], + exactMatch: false, + caseInsensitive: false, + }); + + expect(normalize(template)).toBe( + normalize(` + SELECT DISTINCT ?subject ?predicate ?object + WHERE { + { + # This sub-query will find any matching instances to the given filters and limit the results + SELECT DISTINCT ?subject + WHERE { + ?subject ?pValue ?value . + OPTIONAL { ?subject a ?class } . + FILTER (?pValue IN ()) + FILTER (?class IN ()) + FILTER (regex(str(?value), "JFK", "i")) + } + } + { + # Values and types + ?subject ?predicate ?object + FILTER(isLiteral(?object) || ?predicate = ) + } + } + `), + ); + }); }); diff --git a/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.ts b/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.ts index 0bb603684..afbe282f3 100644 --- a/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.ts +++ b/packages/graph-explorer/src/connector/sparql/keywordSearch/keywordSearchTemplate.ts @@ -109,7 +109,7 @@ export function findSubjectsMatchingFilters( OPTIONAL { ?subject a ?class } . ${getFilterPredicates(request.predicates)} ${getSubjectClasses(request.subjectClasses)} - ${getFilterObject(request.exactMatch, request.searchTerm)} + ${getFilterObject(request.exactMatch, request.searchTerm, request.caseInsensitive)} } ${getLimit(request.limit, request.offset)} `; @@ -125,14 +125,25 @@ function getFilterPredicates(predicates?: string[]) { return `FILTER (?pValue IN (${filteredPredicates.map(idParam).join(", ")}))`; } -function getFilterObject(exactMatch?: boolean, searchTerm?: string) { +function getFilterObject( + exactMatch?: boolean, + searchTerm?: string, + caseInsensitive?: boolean, +) { if (!searchTerm) { return ""; } const escapedSearchTerm = escapeString(searchTerm); - return exactMatch === true - ? `FILTER (?value = "${escapedSearchTerm}")` - : `FILTER (regex(str(?value), "${escapedSearchTerm}", "i"))`; + if (exactMatch === true) { + return caseInsensitive + ? `FILTER (lcase(str(?value)) = lcase("${escapedSearchTerm}"))` + : `FILTER (?value = "${escapedSearchTerm}")`; + } + + // Always use case-insensitive regex for partial match to maintain + // backward compatibility. SPARQL search was always case-insensitive + // before the caseInsensitive parameter was introduced. + return `FILTER (regex(str(?value), "${escapedSearchTerm}", "i"))`; } diff --git a/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts b/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts index 44160068d..7c476a524 100644 --- a/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts +++ b/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts @@ -163,6 +163,7 @@ export function createSparqlExplorer( limit: req.limit, offset: req.offset, exactMatch: req.exactMatch, + caseInsensitive: req.caseInsensitive, }; const response = await keywordSearch( diff --git a/packages/graph-explorer/src/connector/sparql/types.ts b/packages/graph-explorer/src/connector/sparql/types.ts index 73706992b..792f1f786 100644 --- a/packages/graph-explorer/src/connector/sparql/types.ts +++ b/packages/graph-explorer/src/connector/sparql/types.ts @@ -101,9 +101,12 @@ export type SPARQLKeywordSearchRequest = { offset?: number; /** * Filter by exact matching values. - * */ exactMatch?: boolean; + /** + * Perform case-insensitive matching on attribute values. + */ + caseInsensitive?: boolean; }; export type SPARQLBlankNodeNeighborsRequest = { diff --git a/packages/graph-explorer/src/connector/useGEFetchTypes.ts b/packages/graph-explorer/src/connector/useGEFetchTypes.ts index 7e2874ffe..27b68fae3 100644 --- a/packages/graph-explorer/src/connector/useGEFetchTypes.ts +++ b/packages/graph-explorer/src/connector/useGEFetchTypes.ts @@ -169,6 +169,10 @@ export type KeywordSearchRequest = { * Only return exact attribute value matches. */ exactMatch?: boolean; + /** + * Perform case-insensitive matching on attribute values. + */ + caseInsensitive?: boolean; }; export type KeywordSearchResponse = { vertices: Vertex[] }; diff --git a/packages/graph-explorer/src/modules/SearchSidebar/FilterSearchTabContent.tsx b/packages/graph-explorer/src/modules/SearchSidebar/FilterSearchTabContent.tsx index 8055372dc..0f0bde6b5 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/FilterSearchTabContent.tsx +++ b/packages/graph-explorer/src/modules/SearchSidebar/FilterSearchTabContent.tsx @@ -40,6 +40,8 @@ export function FilterSearchTabContent() { onAttributeOptionChange, partialMatch, onPartialMatchChange, + caseInsensitive, + onCaseInsensitiveChange, } = useKeywordSearch(); return ( @@ -113,6 +115,15 @@ export function FilterSearchTabContent() { /> Partial match + diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts index 285b020d1..298b177e2 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts @@ -48,6 +48,15 @@ describe("useKeywordSearch", () => { expect(result.current.partialMatch).toBe(false); }); + it("Should default to case insensitive off", () => { + const { result } = renderHookWithJotai( + () => useKeywordSearch(), + initializeConfigWithQueryEngine("gremlin"), + ); + + expect(result.current.caseInsensitive).toBe(false); + }); + it("Should default to attribute ID", () => { const { result } = renderHookWithJotai( () => useKeywordSearch(), @@ -91,6 +100,15 @@ describe("useKeywordSearch", () => { expect(result.current.partialMatch).toBe(false); }); + it("Should default to case insensitive off", () => { + const { result } = renderHookWithJotai( + () => useKeywordSearch(), + initializeConfigWithQueryEngine("openCypher"), + ); + + expect(result.current.caseInsensitive).toBe(false); + }); + it("Should default to attribute ID", () => { const { result } = renderHookWithJotai( () => useKeywordSearch(), @@ -151,6 +169,15 @@ describe("useKeywordSearch", () => { expect(result.current.partialMatch).toBe(false); }); + it("Should default to case insensitive off", () => { + const { result } = renderHookWithJotai( + () => useKeywordSearch(), + initializeConfigWithRdfLabel, + ); + + expect(result.current.caseInsensitive).toBe(false); + }); + it("Should default to attribute rdfs:label", () => { const { result } = renderHookWithJotai( () => useKeywordSearch(), diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.ts b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.ts index 46865211e..b999d9a07 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.ts @@ -23,7 +23,7 @@ export const selectedAttributeAtom = atomWithReset( SEARCH_TOKENS.NODE_ID, ); export const partialMatchAtom = atomWithReset(false); - +export const caseInsensitiveAtom = atomWithReset(false); /** Gets all the searchable attributes for the selected vertex type */ function useAttributeOptions(selectedVertexType: string) { const allSearchableAttributes = useSearchableAttributes(selectedVertexType); @@ -65,6 +65,7 @@ export default function useKeywordSearch() { selectedAttributeAtom, ); const [partialMatch, setPartialMatch] = useAtom(partialMatchAtom); + const [caseInsensitive, setCaseInsensitive] = useAtom(caseInsensitiveAtom); const exactMatchOptions = [ { label: "Exact", value: "Exact" }, @@ -103,7 +104,7 @@ export default function useKeywordSearch() { setSelectedAttribute(value); const onPartialMatchChange = (value: boolean) => setPartialMatch(value); - + const onCaseInsensitiveChange = (value: boolean) => setCaseInsensitive(value); const attributes = safeSelectedAttribute === SEARCH_TOKENS.ALL_ATTRIBUTES ? attributesOptions @@ -130,6 +131,7 @@ export default function useKeywordSearch() { vertexTypes, searchByAttributes, exactMatch: !partialMatch, + caseInsensitive, }); return { @@ -147,5 +149,7 @@ export default function useKeywordSearch() { partialMatch, exactMatchOptions, onPartialMatchChange, + caseInsensitive, + onCaseInsensitiveChange, }; } diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts index 3d51e0e50..bc64f6071 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts @@ -8,6 +8,7 @@ export type SearchQueryRequest = { vertexTypes?: string[]; searchByAttributes: string[]; exactMatch: boolean; + caseInsensitive: boolean; }; export function useKeywordSearchQuery({ @@ -15,6 +16,7 @@ export function useKeywordSearchQuery({ vertexTypes, searchByAttributes, exactMatch, + caseInsensitive }: SearchQueryRequest) { const updateSchema = useUpdateSchemaFromEntities(); @@ -25,6 +27,7 @@ export function useKeywordSearchQuery({ // Only set these when there is a search term to reduce queries searchByAttributes: debouncedSearchTerm ? searchByAttributes : undefined, exactMatch: debouncedSearchTerm ? exactMatch : undefined, + caseInsensitive: debouncedSearchTerm ? caseInsensitive : undefined, }; const query = useQuery(searchQuery(request, updateSchema)); From 1ffbe1a684544160d4f61d25668014ffedf3f697 Mon Sep 17 00:00:00 2001 From: michael gong Date: Fri, 6 Mar 2026 16:05:37 +1100 Subject: [PATCH 2/2] format --- .../src/modules/SearchSidebar/useKeywordSearchQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts index bc64f6071..08b5cbaaa 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearchQuery.ts @@ -16,7 +16,7 @@ export function useKeywordSearchQuery({ vertexTypes, searchByAttributes, exactMatch, - caseInsensitive + caseInsensitive, }: SearchQueryRequest) { const updateSchema = useUpdateSchemaFromEntities();