Skip to content

Commit 791d9d2

Browse files
committed
feat: add ArangoDBConfig.collectionNamePrefix
1 parent 954bf73 commit 791d9d2

9 files changed

Lines changed: 331 additions & 39 deletions

src/arangodb/aql-generator.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ export interface QueryGenerationOptions {
153153
* See {@link ExecutionOptions.maxProjections} for details.
154154
*/
155155
readonly maxProjections?: number;
156+
157+
/**
158+
* An optional prefix prepended to all collection and view names in generated AQL queries.
159+
* See {@link ArangoDBConfig.collectionNamePrefix} for details.
160+
*/
161+
readonly collectionNamePrefix?: string;
156162
}
157163

158164
class QueryContext {
@@ -583,7 +589,9 @@ register(FlexSearchQueryNode, (node, context) => {
583589
let itemContext = context
584590
.bindVariable(node.itemVariable)
585591
.withExtension(inFlexSearchFilterSymbol, true);
586-
const viewName = getFlexSearchViewNameForRootEntity(node.rootEntityType!);
592+
const viewName = getFlexSearchViewNameForRootEntity(node.rootEntityType!, {
593+
prefix: context.options.collectionNamePrefix,
594+
});
587595
context.addCollectionAccess(viewName, AccessType.EXPLICIT_READ);
588596
return aqlExt.subquery(
589597
aql`FOR ${itemContext.getVariable(node.itemVariable)}`,
@@ -2617,11 +2625,13 @@ function getRelationTraversalForStatements({
26172625
sourceFrag = getFullIDFromKeysFragment(
26182626
plainSourceFrag,
26192627
node.relationSegments[0].relationSide.sourceType,
2628+
context,
26202629
);
26212630
} else {
26222631
sourceFrag = getFullIDFromKeyFragment(
26232632
plainSourceFrag,
26242633
node.relationSegments[0].relationSide.sourceType,
2634+
context,
26252635
);
26262636
}
26272637
} else {
@@ -2733,7 +2743,9 @@ function getRelationTraversalForStatements({
27332743
}
27342744

27352745
context.addCollectionAccess(
2736-
getCollectionNameForRootEntity(segment.relationSide.targetType),
2746+
getCollectionNameForRootEntity(segment.relationSide.targetType, {
2747+
prefix: context.options.collectionNamePrefix,
2748+
}),
27372749
AccessType.IMPLICIT_READ,
27382750
);
27392751

@@ -3041,15 +3053,15 @@ function getFullIDFromKeyNode(
30413053
// special handling to avoid concat if possible - do not alter the behavior
30423054
if (node instanceof LiteralQueryNode && typeof node.value == 'string') {
30433055
// just append the node to the literal key in JavaScript and bind it as a string
3044-
return aql`${getCollectionNameForRootEntity(rootEntityType) + '/' + node.value}`;
3056+
return aql`${getCollectionNameForRootEntity(rootEntityType, { prefix: context.options.collectionNamePrefix }) + '/' + node.value}`;
30453057
}
30463058
if (node instanceof RootEntityIDQueryNode) {
30473059
// access the _id field. processNode(node) would access the _key field instead.
30483060
return aql`${processNode(node.objectNode, context)}._id`;
30493061
}
30503062

30513063
// fall back to general case
3052-
return getFullIDFromKeyFragment(processNode(node, context), rootEntityType);
3064+
return getFullIDFromKeyFragment(processNode(node, context), rootEntityType, context);
30533065
}
30543066

30553067
function getFullIDsFromKeysNode(
@@ -3069,29 +3081,34 @@ function getFullIDsFromKeysNode(
30693081
isReadonlyArray(idsNode.value) &&
30703082
idsNode.value.every((v) => typeof v === 'string')
30713083
) {
3072-
const collName = getCollectionNameForRootEntity(rootEntityType);
3084+
const collName = getCollectionNameForRootEntity(rootEntityType, {
3085+
prefix: context.options.collectionNamePrefix,
3086+
});
30733087
const ids = idsNode.value.map((val) => collName + '/' + val);
30743088
return aql.value(ids);
30753089
}
30763090

3077-
return getFullIDFromKeysFragment(processNode(idsNode, context), rootEntityType);
3091+
return getFullIDFromKeysFragment(processNode(idsNode, context), rootEntityType, context);
30783092
}
30793093

30803094
function getFullIDFromKeyFragment(
30813095
keyFragment: AQLFragment,
30823096
rootEntityType: RootEntityType,
3097+
context: QueryContext,
30833098
): AQLFragment {
3084-
return aql`CONCAT(${getCollectionNameForRootEntity(rootEntityType) + '/'}, ${keyFragment})`;
3099+
return aql`CONCAT(${getCollectionNameForRootEntity(rootEntityType, { prefix: context.options.collectionNamePrefix }) + '/'}, ${keyFragment})`;
30853100
}
30863101

30873102
function getFullIDFromKeysFragment(
30883103
keysFragment: AQLFragment,
30893104
rootEntityType: RootEntityType,
3105+
context: QueryContext,
30903106
): AQLFragment {
30913107
const idVar = aql.variable('id');
30923108
return aql`(FOR ${idVar} IN ${keysFragment} RETURN ${getFullIDFromKeyFragment(
30933109
idVar,
30943110
rootEntityType,
3111+
context,
30953112
)})`;
30963113
}
30973114

@@ -3189,18 +3206,21 @@ export function getAQLQuery(
31893206
clock: options.clock ?? new DefaultClock(),
31903207
idGenerator: options.idGenerator ?? new UUIDGenerator(),
31913208
maxProjections: options.maxProjections,
3209+
collectionNamePrefix: options.collectionNamePrefix,
31923210
}),
31933211
);
31943212
}
31953213

31963214
function getCollectionForBilling(accessType: AccessType, context: QueryContext) {
3197-
const name = billingCollectionName;
3215+
const name = (context.options.collectionNamePrefix ?? '') + billingCollectionName;
31983216
context.addCollectionAccess(name, accessType);
31993217
return aql.collection(name);
32003218
}
32013219

32023220
function getCollectionForType(type: RootEntityType, accessType: AccessType, context: QueryContext) {
3203-
const name = getCollectionNameForRootEntity(type);
3221+
const name = getCollectionNameForRootEntity(type, {
3222+
prefix: context.options.collectionNamePrefix,
3223+
});
32043224
context.addCollectionAccess(name, accessType);
32053225
return aql.collection(name);
32063226
}
@@ -3210,7 +3230,9 @@ function getCollectionForRelation(
32103230
accessType: AccessType,
32113231
context: QueryContext,
32123232
) {
3213-
const name = getCollectionNameForRelation(relation);
3233+
const name = getCollectionNameForRelation(relation, {
3234+
prefix: context.options.collectionNamePrefix,
3235+
});
32143236
context.addCollectionAccess(name, accessType);
32153237
return aql.collection(name);
32163238
}
@@ -3225,7 +3247,9 @@ function getSimpleFollowEdgeFragment(
32253247
): AQLFragment {
32263248
const dir = node.relationSide.isFromSide ? aql`OUTBOUND` : aql`INBOUND`;
32273249
context.addCollectionAccess(
3228-
getCollectionNameForRootEntity(node.relationSide.targetType),
3250+
getCollectionNameForRootEntity(node.relationSide.targetType, {
3251+
prefix: context.options.collectionNamePrefix,
3252+
}),
32293253
AccessType.IMPLICIT_READ,
32303254
);
32313255
return aql`${dir} ${processNode(node.sourceEntityNode, context)} ${getCollectionForRelation(

src/arangodb/arango-basics.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@ import { decapitalize } from '../core/utils/utils.js';
44

55
export const billingCollectionName = 'billingEntities';
66

7-
export function getCollectionNameForRootEntity(type: RootEntityType) {
8-
return decapitalize(type.pluralName);
7+
export function getCollectionNameForRootEntity(
8+
type: RootEntityType,
9+
{ prefix }: { prefix: string | undefined },
10+
) {
11+
return (prefix ?? '') + decapitalize(type.pluralName);
912
}
1013

11-
export function getCollectionNameForRelation(relation: Relation) {
12-
return getCollectionNameForRootEntity(relation.fromType) + '_' + relation.fromField.name;
14+
export function getCollectionNameForRelation(
15+
relation: Relation,
16+
{ prefix }: { prefix: string | undefined },
17+
) {
18+
return (
19+
getCollectionNameForRootEntity(relation.fromType, { prefix }) +
20+
'_' +
21+
relation.fromField.name
22+
);
1323
}

src/arangodb/arangodb-adapter.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ export class ArangoDBAdapter implements DatabaseAdapter {
105105
private schemaContext?: ProjectOptions,
106106
) {
107107
this.logger = getArangoDBLogger(schemaContext);
108+
if (
109+
config.collectionNamePrefix !== undefined &&
110+
!/^[a-zA-Z0-9_]+$/.test(config.collectionNamePrefix)
111+
) {
112+
throw new Error(
113+
`ArangoDBConfig.collectionNamePrefix must consist only of letters, digits, and underscores, but got: ${JSON.stringify(config.collectionNamePrefix)}`,
114+
);
115+
}
108116
this.db = initDatabase(config);
109117
this.analyzer = new SchemaAnalyzer(config, schemaContext);
110118
this.migrationPerformer = new MigrationPerformer(config);
@@ -338,6 +346,7 @@ export class ArangoDBAdapter implements DatabaseAdapter {
338346
clock: options.clock,
339347
idGenerator: options.idGenerator,
340348
maxProjections: options.maxProjections,
349+
collectionNamePrefix: this.config.collectionNamePrefix,
341350
});
342351
executableQueries = aqlQuery.getExecutableQueries();
343352
} finally {
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { gql } from 'graphql-tag';
2+
import { describe, expect, it } from 'vitest';
3+
import { EntitiesQueryNode } from '../core/query-tree/queries.js';
4+
import { createSimpleModel } from '../testing/utils/create-simple-model.js';
5+
import { getAQLQuery } from './aql-generator.js';
6+
import {
7+
billingCollectionName,
8+
getCollectionNameForRelation,
9+
getCollectionNameForRootEntity,
10+
} from './arango-basics.js';
11+
import { ArangoDBAdapter } from './arangodb-adapter.js';
12+
import {
13+
getFlexSearchViewNameForRootEntity,
14+
getRequiredViewsFromModel,
15+
} from './schema-migration/arango-search-helpers.js';
16+
import { getRequiredIndicesFromModel } from './schema-migration/index-helpers.js';
17+
18+
describe('collectionNamePrefix', () => {
19+
const model = createSimpleModel(gql`
20+
type Order @rootEntity(flexSearch: true) {
21+
orderNumber: String @key @flexSearch @index
22+
deliveries: [Delivery] @relation
23+
}
24+
25+
type Delivery @rootEntity {
26+
trackingNumber: String @index
27+
}
28+
`);
29+
const orderType = model.getRootEntityTypeOrThrow('Order');
30+
const deliveryType = model.getRootEntityTypeOrThrow('Delivery');
31+
const orderToDeliveryRelation = orderType.relations[0];
32+
33+
describe('getCollectionNameForRootEntity', () => {
34+
it('returns unprefixed name when prefix is undefined', () => {
35+
expect(getCollectionNameForRootEntity(orderType, { prefix: undefined })).toBe('orders');
36+
});
37+
38+
it('prepends the prefix to the collection name', () => {
39+
expect(getCollectionNameForRootEntity(orderType, { prefix: 'myapp_' })).toBe(
40+
'myapp_orders',
41+
);
42+
});
43+
44+
it('works with an empty string prefix', () => {
45+
expect(getCollectionNameForRootEntity(orderType, { prefix: '' })).toBe('orders');
46+
});
47+
});
48+
49+
describe('getCollectionNameForRelation', () => {
50+
it('returns unprefixed name when prefix is undefined', () => {
51+
expect(
52+
getCollectionNameForRelation(orderToDeliveryRelation, { prefix: undefined }),
53+
).toBe('orders_deliveries');
54+
});
55+
56+
it('prepends the prefix to the edge collection name', () => {
57+
expect(
58+
getCollectionNameForRelation(orderToDeliveryRelation, { prefix: 'myapp_' }),
59+
).toBe('myapp_orders_deliveries');
60+
});
61+
});
62+
63+
describe('getFlexSearchViewNameForRootEntity', () => {
64+
it('returns unprefixed view name when prefix is undefined', () => {
65+
expect(getFlexSearchViewNameForRootEntity(orderType, { prefix: undefined })).toBe(
66+
'flex_view_orders',
67+
);
68+
});
69+
70+
it('prepends the prefix before flex_view_ in the view name', () => {
71+
expect(getFlexSearchViewNameForRootEntity(orderType, { prefix: 'myapp_' })).toBe(
72+
'myapp_flex_view_orders',
73+
);
74+
});
75+
});
76+
77+
describe('getRequiredViewsFromModel', () => {
78+
it('returns unprefixed names when prefix is undefined', () => {
79+
const views = getRequiredViewsFromModel(model, { prefix: undefined });
80+
expect(views).toHaveLength(1);
81+
expect(views[0].viewName).toBe('flex_view_orders');
82+
expect(views[0].collectionName).toBe('orders');
83+
});
84+
85+
it('uses the prefix for both view name and collection name', () => {
86+
const views = getRequiredViewsFromModel(model, { prefix: 'myapp_' });
87+
expect(views).toHaveLength(1);
88+
expect(views[0].viewName).toBe('myapp_flex_view_orders');
89+
expect(views[0].collectionName).toBe('myapp_orders');
90+
});
91+
});
92+
93+
describe('getRequiredIndicesFromModel', () => {
94+
it('returns unprefixed collection names when prefix is undefined', () => {
95+
const indices = getRequiredIndicesFromModel(model, { prefix: undefined });
96+
const orderIndices = indices.filter((i) => i.rootEntity === orderType);
97+
expect(orderIndices.every((i) => i.collectionName === 'orders')).toBe(true);
98+
});
99+
100+
it('prepends the prefix to collection names in index definitions', () => {
101+
const indices = getRequiredIndicesFromModel(model, { prefix: 'myapp_' });
102+
const orderIndices = indices.filter((i) => i.rootEntity === orderType);
103+
const deliveryIndices = indices.filter((i) => i.rootEntity === deliveryType);
104+
expect(orderIndices.every((i) => i.collectionName === 'myapp_orders')).toBe(true);
105+
expect(deliveryIndices.every((i) => i.collectionName === 'myapp_deliveries')).toBe(
106+
true,
107+
);
108+
});
109+
});
110+
111+
describe('AQL generation', () => {
112+
it('generates unprefixed collection names when no prefix is set', () => {
113+
const aqlCompound = getAQLQuery(new EntitiesQueryNode(orderType));
114+
expect(aqlCompound.readAccessedCollections).toContain('orders');
115+
});
116+
117+
it('generates prefixed collection names in AQL when collectionNamePrefix is set', () => {
118+
const aqlCompound = getAQLQuery(new EntitiesQueryNode(orderType), {
119+
collectionNamePrefix: 'myapp_',
120+
});
121+
expect(aqlCompound.readAccessedCollections).toContain('myapp_orders');
122+
expect(aqlCompound.readAccessedCollections).not.toContain('orders');
123+
});
124+
125+
it('includes prefix in AQL bind parameters', () => {
126+
const aqlCompound = getAQLQuery(new EntitiesQueryNode(orderType), {
127+
collectionNamePrefix: 'myapp_',
128+
});
129+
const query = aqlCompound.getExecutableQueries()[0];
130+
expect(query.boundValues).toHaveProperty('@myapp_orders', 'myapp_orders');
131+
});
132+
});
133+
134+
describe('billing collection', () => {
135+
it('billingCollectionName is a plain string constant', () => {
136+
expect(billingCollectionName).toBe('billingEntities');
137+
});
138+
});
139+
140+
describe('ArangoDBAdapter constructor validation', () => {
141+
const validConfig = {
142+
url: 'http://localhost:8529',
143+
databaseName: 'test',
144+
};
145+
146+
it('accepts a valid prefix', () => {
147+
expect(
148+
() =>
149+
new ArangoDBAdapter({
150+
...validConfig,
151+
collectionNamePrefix: 'myapp_',
152+
}),
153+
).not.toThrow();
154+
});
155+
156+
it('accepts undefined prefix', () => {
157+
expect(() => new ArangoDBAdapter({ ...validConfig })).not.toThrow();
158+
});
159+
160+
it('throws on a prefix with invalid characters (hyphen)', () => {
161+
expect(
162+
() =>
163+
new ArangoDBAdapter({
164+
...validConfig,
165+
collectionNamePrefix: 'my-app_',
166+
}),
167+
).toThrow(/collectionNamePrefix/);
168+
});
169+
170+
it('throws on a prefix with a space', () => {
171+
expect(
172+
() =>
173+
new ArangoDBAdapter({
174+
...validConfig,
175+
collectionNamePrefix: 'my app_',
176+
}),
177+
).toThrow(/collectionNamePrefix/);
178+
});
179+
180+
it('throws on a prefix with a dot', () => {
181+
expect(
182+
() =>
183+
new ArangoDBAdapter({
184+
...validConfig,
185+
collectionNamePrefix: 'my.app_',
186+
}),
187+
).toThrow(/collectionNamePrefix/);
188+
});
189+
190+
it('throws on an empty string prefix', () => {
191+
expect(
192+
() =>
193+
new ArangoDBAdapter({
194+
...validConfig,
195+
collectionNamePrefix: '',
196+
}),
197+
).toThrow(/collectionNamePrefix/);
198+
});
199+
});
200+
});

0 commit comments

Comments
 (0)