Skip to content

Commit c05f1af

Browse files
committed
feat(typesync pkg): create utils to convert name to camelCase or PascalCase
1 parent f75247c commit c05f1af

File tree

6 files changed

+288
-35
lines changed

6 files changed

+288
-35
lines changed

packages/typesync/package.json

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,17 @@
2626
"./Mapping": {
2727
"types": "./dist/Mapping.d.ts",
2828
"default": "./dist/Mapping.js"
29+
},
30+
"./Utils": {
31+
"types": "./dist/Utils.d.ts",
32+
"default": "./dist/Utils.js"
2933
}
3034
},
3135
"sideEffects": [],
3236
"scripts": {
3337
"build": "tsc -b --force tsconfig.build.json && babel dist --plugins annotate-pure-calls --out-dir dist --source-maps && node ../../scripts/package.mjs",
3438
"test": "vitest"
3539
},
36-
"peerDependencies": {
37-
"@graphprotocol/hypergraph": "workspace:*"
38-
},
39-
"devDependencies": {
40-
"@graphprotocol/hypergraph": "workspace:*"
41-
},
4240
"dependencies": {
4341
"@graphprotocol/grc-20": "^0.17.1",
4442
"effect": "^3.16.5"

packages/typesync/src/Mapping.ts

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { type CreatePropertyParams, Graph, Id as Grc20Id, type Op } from '@graphprotocol/grc-20';
22
import { Array as EffectArray, Schema as EffectSchema, pipe } from 'effect';
33

4+
import { namesAreUnique, toCamelCase, toPascalCase } from './Utils.js';
5+
46
/**
57
* Mappings for a schema type and its properties/relations
68
*
@@ -24,16 +26,6 @@ export type MappingEntry = {
2426
[key: string]: Grc20Id.Id;
2527
}
2628
| undefined;
27-
/**
28-
* Record of schema type relation names to the `Id.Id` of the relation in the Knowledge Graph
29-
*
30-
* @since 0.0.1
31-
*/
32-
relations?:
33-
| {
34-
[key: string]: Grc20Id.Id;
35-
}
36-
| undefined;
3729
};
3830

3931
/**
@@ -55,10 +47,8 @@ export type MappingEntry = {
5547
* properties: {
5648
* name: Id.Id('3808e060-fb4a-4d08-8069-35b8c8a1902b'),
5749
* description: Id.Id('1f0d9007-8da2-4b28-ab9f-3bc0709f4837'),
50+
* speaker: Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6')
5851
* },
59-
* relations: {
60-
* account: Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6')
61-
* }
6252
* }
6353
* }
6454
* ```
@@ -69,21 +59,43 @@ export type Mapping = {
6959
[key: string]: MappingEntry;
7060
};
7161

62+
/**
63+
* @since 0.0.1
64+
*/
7265
export type DataTypeRelation = `Relation(${string})`;
66+
/**
67+
* @since 0.0.1
68+
*/
7369
export function isDataTypeRelation(val: string): val is DataTypeRelation {
7470
return /^Relation\((.+)\)$/.test(val);
7571
}
72+
/**
73+
* @since 0.0.1
74+
*/
7675
export const SchemaDataTypeRelation = EffectSchema.NonEmptyTrimmedString.pipe(
7776
EffectSchema.filter((val) => isDataTypeRelation(val)),
7877
);
78+
/**
79+
* @since 0.0.1
80+
*/
7981
export type SchemaDataTypeRelation = typeof SchemaDataTypeRelation.Type;
80-
82+
/**
83+
* @since 0.0.1
84+
*/
8185
export const SchemaDataType = EffectSchema.Union(
8286
EffectSchema.Literal('Text', 'Number', 'Boolean', 'Date', 'Point', 'Url'),
8387
SchemaDataTypeRelation,
8488
);
89+
/**
90+
* @since 0.0.1
91+
*/
8592
export type SchemaDataType = typeof SchemaDataType.Type;
8693

94+
/**
95+
* Represents the user-built schema object to generate a `Mappings` definition for
96+
*
97+
* @since 0.0.1
98+
*/
8799
export const Schema = EffectSchema.Struct({
88100
types: EffectSchema.Array(
89101
EffectSchema.Struct({
@@ -95,9 +107,23 @@ export const Schema = EffectSchema.Struct({
95107
knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID),
96108
dataType: SchemaDataType,
97109
}),
98-
).pipe(EffectSchema.minItems(1)),
110+
).pipe(
111+
EffectSchema.minItems(1),
112+
EffectSchema.filter(namesAreUnique, {
113+
identifier: 'DuplicatePropertyNames',
114+
jsonSchema: {},
115+
description: 'The property.name must be unique across all properties in the type',
116+
}),
117+
),
118+
}),
119+
).pipe(
120+
EffectSchema.minItems(1),
121+
EffectSchema.filter(namesAreUnique, {
122+
identifier: 'DuplicateTypeNames',
123+
jsonSchema: {},
124+
description: 'The type.name must be unique across all types in the schema',
99125
}),
100-
).pipe(EffectSchema.minItems(1)),
126+
),
101127
}).annotations({
102128
identifier: 'typesync/Schema',
103129
title: 'TypeSync app Schema',
@@ -123,8 +149,27 @@ export const Schema = EffectSchema.Struct({
123149
},
124150
],
125151
});
152+
/**
153+
* @since 0.0.1
154+
*/
126155
export type Schema = typeof Schema.Type;
156+
/**
157+
* @since 0.0.1
158+
*/
159+
export const SchemaKnownDecoder = EffectSchema.decodeSync(Schema);
160+
/**
161+
* @since 0.0.1
162+
*/
163+
export const SchemaUnknownDecoder = EffectSchema.decodeUnknownSync(Schema);
127164

165+
/**
166+
*
167+
*
168+
* @since 0.0.1
169+
*
170+
* @param schema user-built and submitted schema
171+
* @returns the generated [Mapping] definition from the submitted schema
172+
*/
128173
export async function generateMapping(schema: Schema): Promise<Mapping> {
129174
const entries: Array<MappingEntry & { typeName: string }> = [];
130175
const ops: Array<Op> = [];
@@ -134,35 +179,45 @@ export async function generateMapping(schema: Schema): Promise<Mapping> {
134179
for (const property of type.properties) {
135180
if (property.knowledgeGraphId) {
136181
typePropertyIds.push({ propName: property.name, id: Grc20Id.Id(property.knowledgeGraphId) });
182+
137183
continue;
138184
}
139185
// create op for creating type property
186+
const dataType = mapSchemaDataTypeToGRC20PropDataType(property.dataType);
187+
if (dataType === 'RELATION') {
188+
const { id, ops: createTypePropOp } = Graph.createProperty({
189+
name: property.name,
190+
dataType: 'RELATION',
191+
relationValueTypes: [],
192+
properties: [],
193+
});
194+
typePropertyIds.push({ propName: property.name, id });
195+
ops.push(...createTypePropOp);
196+
197+
continue;
198+
}
140199
const { id, ops: createTypePropOp } = Graph.createProperty({
141200
name: property.name,
142201
dataType: mapSchemaDataTypeToGRC20PropDataType(property.dataType),
143202
});
144203
typePropertyIds.push({ propName: property.name, id });
145-
// add createProperty ops to array to submit in batch to KG
146204
ops.push(...createTypePropOp);
147205
}
148206

149207
const properties: MappingEntry['properties'] = pipe(
150208
typePropertyIds,
151209
EffectArray.reduce({} as NonNullable<MappingEntry['properties']>, (props, { propName, id }) => {
152-
props[propName] = id;
210+
props[toCamelCase(propName)] = id;
153211

154212
return props;
155213
}),
156214
);
157215

158-
const relations: MappingEntry['relations'] = undefined;
159-
160216
if (type.knowledgeGraphId) {
161217
entries.push({
162-
typeName: type.name,
218+
typeName: toPascalCase(type.name),
163219
typeIds: [Grc20Id.Id(type.knowledgeGraphId)],
164220
properties,
165-
relations,
166221
});
167222
continue;
168223
}
@@ -174,14 +229,15 @@ export async function generateMapping(schema: Schema): Promise<Mapping> {
174229
ops.push(...createTypeOp);
175230

176231
entries.push({
177-
typeName: type.name,
232+
typeName: toPascalCase(type.name),
178233
typeIds: [id],
179234
properties,
180-
relations,
181235
});
182236
}
183237

184-
// @todo send ops to Knowledge Graph
238+
/**
239+
* @todo publish the schema onchain to the Knowledge Graph with hypergraph connect app to the application space
240+
*/
185241

186242
return pipe(
187243
entries,

packages/typesync/src/Utils.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { Data, String as EffectString } from 'effect';
2+
3+
/**
4+
* Takes the input string and returns the camelCase equivalent
5+
*
6+
* @example
7+
* ```ts
8+
* import * as Utils from '@graphprotocol/typesync/Utils'
9+
*
10+
* expect(Utils.toCamelCase('Address line 1')).toEqual('addressLine1');
11+
* expect(Utils.toCamelCase('AddressLine1')).toEqual('addressLine1');
12+
* expect(Utils.toCamelCase('addressLine1')).toEqual('addressLine1');
13+
* expect(Utils.toCamelCase('address_line_1')).toEqual('addressLine1');
14+
* expect(Utils.toCamelCase('address-line-1')).toEqual('addressLine1');
15+
* expect(Utils.toCamelCase('address-line_1')).toEqual('addressLine1');
16+
* expect(Utils.toCamelCase('address-line 1')).toEqual('addressLine1');
17+
* expect(Utils.toCamelCase('ADDRESS_LINE_1')).toEqual('addressLine1');
18+
* ```
19+
*
20+
* @since 0.0.1
21+
*
22+
* @param str input string
23+
* @returns camelCased value of the input string
24+
*/
25+
export function toCamelCase(str: string): string {
26+
if (EffectString.isEmpty(str)) {
27+
throw new InvalidInputError({ input: str, cause: 'Input is empty' });
28+
}
29+
30+
let result = '';
31+
let capitalizeNext = false;
32+
let i = 0;
33+
34+
// Skip leading non-alphanumeric characters
35+
while (i < EffectString.length(str) && !/[a-zA-Z0-9]/.test(str[i])) {
36+
i++;
37+
}
38+
39+
for (; i < EffectString.length(str); i++) {
40+
const char = str[i];
41+
42+
if (/[a-zA-Z0-9]/.test(char)) {
43+
if (capitalizeNext) {
44+
result += EffectString.toUpperCase(char);
45+
capitalizeNext = false;
46+
} else if (EffectString.length(result) === 0) {
47+
// First character should always be lowercase
48+
result += EffectString.toLowerCase(char);
49+
} else if (/[A-Z]/.test(char) && i > 0 && /[a-z0-9]/.test(str[i - 1])) {
50+
// Capital letter following lowercase/number - this indicates a word boundary
51+
// So we need to capitalize this letter (it starts a new word)
52+
result += EffectString.toUpperCase(char);
53+
} else {
54+
result += EffectString.toLowerCase(char);
55+
}
56+
} else {
57+
// Non-alphanumeric character - set flag to capitalize next letter
58+
capitalizeNext = EffectString.length(result) > 0; // Only capitalize if we have existing content
59+
}
60+
}
61+
62+
return result;
63+
}
64+
65+
/**
66+
* Takes the input string and returns the PascalCase equivalent
67+
*
68+
* @example
69+
* ```ts
70+
* import * as Utils from '@graphprotocol/typesync/Utils'
71+
*
72+
* expect(Utils.toPascalCase('Address line 1')).toEqual('AddressLine1');
73+
* expect(Utils.toPascalCase('AddressLine1')).toEqual('AddressLine1');
74+
* expect(Utils.toPascalCase('addressLine1')).toEqual('AddressLine1');
75+
* expect(Utils.toPascalCase('address_line_1')).toEqual('AddressLine1');
76+
* expect(Utils.toPascalCase('address-line-1')).toEqual('AddressLine1');
77+
* expect(Utils.toPascalCase('address-line_1')).toEqual('AddressLine1');
78+
* expect(Utils.toPascalCase('address-line 1')).toEqual('AddressLine1');
79+
* expect(Utils.toPascalCase('ADDRESS_LINE_1')).toEqual('AddressLine1');
80+
* ```
81+
*
82+
* @since 0.0.1
83+
*
84+
* @param str input string
85+
* @returns PascalCased value of the input string
86+
*/
87+
export function toPascalCase(str: string): string {
88+
if (EffectString.isEmpty(str)) {
89+
throw new InvalidInputError({ input: str, cause: 'Input is empty' });
90+
}
91+
92+
let result = '';
93+
let capitalizeNext = true; // Start with true to capitalize the first letter
94+
let i = 0;
95+
96+
// Skip leading non-alphanumeric characters
97+
while (i < EffectString.length(str) && !/[a-zA-Z0-9]/.test(str[i])) {
98+
i++;
99+
}
100+
101+
for (; i < EffectString.length(str); i++) {
102+
const char = str[i];
103+
104+
if (/[a-zA-Z0-9]/.test(char)) {
105+
if (capitalizeNext) {
106+
result += EffectString.toUpperCase(char);
107+
capitalizeNext = false;
108+
} else if (/[A-Z]/.test(char) && i > 0 && /[a-z0-9]/.test(str[i - 1])) {
109+
// Capital letter following lowercase/number - this indicates a word boundary
110+
// So we need to capitalize this letter (it starts a new word)
111+
result += EffectString.toUpperCase(char);
112+
} else {
113+
result += EffectString.toLowerCase(char);
114+
}
115+
} else {
116+
// Non-alphanumeric character - set flag to capitalize next letter
117+
capitalizeNext = true;
118+
}
119+
}
120+
121+
return result;
122+
}
123+
124+
export class InvalidInputError extends Data.TaggedError('/typesync/errors/InvalidInputError')<{
125+
readonly input: string;
126+
readonly cause: unknown;
127+
}> {}
128+
129+
/**
130+
* Adds schema validation that the array of objects with property `name` only has unique names
131+
*
132+
* @example <caption>only unique names -> returns true</caption>
133+
* ```ts
134+
* const types = [{name:'Account'}, {name:'Event'}]
135+
* expect(namesAreUnique(types)).toEqual(true)
136+
* ```
137+
*
138+
* @example <caption>duplicate name -> returns false</caption>
139+
* ```ts
140+
* const types = [{name:'Account'}, {name:'Event'}, {name:'Account'}]
141+
* expect(namesAreUnique(types)).toEqual(false)
142+
* ```
143+
*/
144+
export function namesAreUnique<T extends { readonly name: string }>(entries: ReadonlyArray<T>): boolean {
145+
const names = new Set<string>();
146+
147+
for (const entry of entries) {
148+
const name = EffectString.toLowerCase(entry.name);
149+
if (names.has(name)) {
150+
return false;
151+
}
152+
names.add(name);
153+
}
154+
155+
return true;
156+
}

packages/typesync/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * as Mapping from './Mapping.js';
2+
export * as Utils from './Utils.js';

0 commit comments

Comments
 (0)