Skip to content

Commit

Permalink
Merge pull request #81 from Blitzapps/feat-surql-define
Browse files Browse the repository at this point in the history
client.define() for surrealdb and fixes for typedb
  • Loading branch information
lveillard authored Sep 4, 2024
2 parents af636e1 + 97e70ef commit b49406e
Show file tree
Hide file tree
Showing 24 changed files with 3,296 additions and 2,010 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,6 @@
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/node_modules": true
}
},
"terminal.integrated.scrollback": 3000,
}
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

📝 following beta format X.Y.Z where Y = breaking change and Z = feature and fix. Later => FAIL.FEATURE.FIX

## 0.11.1(2024-09-04)

- Feat: Generate surrealDB schema (client.define() works with surrealDB now)
- Fix: TypeDB schema generation has been fixed. (It still needs some enhancements tho)
- Test: Added some tests for extended classes

## 0.11.0(2024-09-01)

- Feat: Working queries and basic mutations for surrealDB
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blitznocode/blitz-orm",
"version": "0.11.0",
"version": "0.11.1",
"author": "blitznocode.com",
"description": "Blitz-orm is an Object Relational Mapper (ORM) for graph databases that uses a JSON query language called Blitz Query Language (BQL). BQL is similar to GraphQL but uses JSON instead of strings. This makes it easier to build dynamic queries.",
"main": "dist/index.mjs",
Expand Down Expand Up @@ -36,6 +36,7 @@
"test:surrealdb-query:refs": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/test.sh tests/unit/queries/query.test.ts",
"test:surrealdb-mutation:edges": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=edges ./tests/test.sh tests/unit/mutations",
"test:surrealdb-mutation:refs": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/test.sh tests/unit/mutations",
"test:surrealdb-schema": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/test.sh tests/unit/schema",
"test:typedb-ignoreTodo": "cross-env BORM_TEST_ADAPTER=typeDB vitest run tests/unit/allTests.test.ts -t \"^(?!.*TODO{.*[T].*}:).*\" ",
"test:typedb-mutation": "cross-env BORM_TEST_ADAPTER=typeDB vitest run unit/mutations",
"test:typedb-query": "cross-env BORM_TEST_ADAPTER=typeDB vitest run tests/unit/queries --watch",
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/surrealDB/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ export const sanitizeNameSurrealDB = (name: string) => {
};

export const tempSanitizeVarSurrealDB = (input: string): string =>
input.replace(/[ \-+*/=!@#$%^&()\[\]{}|\\;:'"<>,.?~`]/g, '');
input.replace(/[ \-+*/=!@#$%^&()[\]{}|\\;:'"<>,.?~`]/g, '');
271 changes: 271 additions & 0 deletions src/adapters/surrealDB/schema/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import type {
EnrichedBormEntity,
EnrichedBormRelation,
EnrichedBormSchema,
EnrichedDataField,
EnrichedLinkField,
EnrichedRoleField,
Validations,
} from '../../../types';
import { sanitizeNameSurrealDB } from '../helpers';

const INDENTATION = '\t' as const;
const indent = (n: number): string => INDENTATION.repeat(n);

const indentPar = (str: string, level: number): string =>
str
.split('\n')
.map((line) => (line.trim() ? `${indent(level)}${line}` : line))
.join('\n');

type SchemaItem = EnrichedBormEntity | EnrichedBormRelation;

const convertBQLToSurQL = (schema: EnrichedBormSchema): string => {
const header = `USE NS test;
USE DB test;
BEGIN TRANSACTION;
`;

const entities = `-- ENTITIES\n${convertSchemaItems(schema.entities)}`;
const relations = `\n-- RELATIONS\n${convertSchemaItems(schema.relations)}`;
const utilityFunctions = addUtilityFunctions();

return `${header}${entities}${relations}${utilityFunctions}COMMIT TRANSACTION;`;
};

const convertSchemaItems = (items: Record<string, SchemaItem>): string =>
Object.entries(items)
.map(([name, item]) => convertSchemaItem(sanitizeNameSurrealDB(name), item, 1))
.join('\n\n');

const convertSchemaItem = (name: string, item: SchemaItem, level: number): string => {
const baseDefinition = `${indent(level)}DEFINE TABLE ${name} SCHEMAFULL PERMISSIONS FULL;${'extends' in item && item.extends ? ` //EXTENDS ${item.extends};` : ''}`;
const dataFields = indentPar(`-- DATA FIELDS\n${convertDataFields(item.dataFields ?? [], name, level)}`, level + 1);
const linkFields = indentPar(`\n-- LINK FIELDS\n${convertLinkFields(item.linkFields ?? [], name, level)}`, level + 1);
const roles = 'roles' in item ? indentPar(`\n-- ROLES\n${convertRoles(item.roles, name, level)}`, level + 1) : '';

return `${baseDefinition}\n${dataFields}${linkFields}${roles}`;
};

const convertDataFields = (dataFields: EnrichedDataField[], parentName: string, level: number): string =>
dataFields
.map((field) => {
if (field.path === 'id') {
return ''; //skip id fields for now, we will migrate it to a different name later like _id
}
const fieldType = mapContentTypeToSurQL(field.contentType, field.validations);
const baseDefinition = `${indent(level)}DEFINE FIELD ${field.path} ON TABLE ${parentName}${['FLEX', 'JSON'].includes(field.contentType) ? ' FLEXIBLE' : ''}`; //TTODO: Better type json

if (field.isVirtual) {
const dbValue = field.dbValue?.surrealDB;
if (!dbValue) {
return ''; //it means is computed in BORM instead
}
return `${baseDefinition} VALUE ${dbValue};`;
}
return `${baseDefinition} TYPE ${fieldType};`;
})
.filter(Boolean)
.join('\n');

const convertLinkFields = (linkFields: EnrichedLinkField[], parentName: string, level: number): string =>
linkFields
.map((linkField) => {
const fieldType =
//linkField.cardinality === 'MANY' ? `array<record<${linkField.relation}>>` : `record<${linkField.relation}>`; //todo: uncomment once surrealDB has smart transactions
linkField.cardinality === 'MANY'
? `option<array<record<${linkField.$things.map(sanitizeNameSurrealDB).join('|')}>>>`
: `option<record<${linkField.$things.map(sanitizeNameSurrealDB).join('|')}>>`;

const baseDefinition = `${indent(level)}DEFINE FIELD ${sanitizeNameSurrealDB(linkField.path)} ON TABLE ${parentName}`;

if (linkField.isVirtual) {
const dbValue = linkField.dbValue?.surrealDB;
if (!dbValue) {
return ''; //it means is computed in BORM instead
}

return `${baseDefinition} VALUE ${dbValue};`;
}

if (linkField.target === 'role') {
const relationLinkField = linkFields.find(
(lf) => lf.target === 'relation' && lf.relation === linkField.relation,
);
const targetRole = linkField.oppositeLinkFieldsPlayedBy?.[0];
const targetPath = targetRole.plays;

if (!targetPath || linkField.oppositeLinkFieldsPlayedBy?.length !== 1) {
throw new Error(`Invalid link field: ${linkField.path}`);
}

const type =
linkField.cardinality === 'ONE'
? `record<${sanitizeNameSurrealDB(linkField.relation)}>`
: `array<record<${sanitizeNameSurrealDB(linkField.relation)}>>`;

const pathToRelation = sanitizeNameSurrealDB(linkField.pathToRelation || '');
const relationPath = `${pathToRelation}.${targetPath}`;

const baseField =
linkField.cardinality === 'ONE'
? `${baseDefinition} VALUE <future> {RETURN SELECT VALUE ${relationPath} FROM ONLY $this};`
: `${baseDefinition} VALUE <future> {array::distinct(SELECT VALUE array::flatten(${relationPath} || []) FROM ONLY $this)};`;
const supportField = relationLinkField?.path
? ''
: `${indent(level + 1)}DEFINE FIELD ${pathToRelation} ON TABLE ${parentName} TYPE option<${type}>;`;

return [baseField, supportField].join('\n');
}
if (linkField.target === 'relation') {
const fieldDefinition = `${indent(level)}DEFINE FIELD ${sanitizeNameSurrealDB(linkField.path)} ON TABLE ${parentName} TYPE ${fieldType};`;
return `${fieldDefinition}`;
}
throw new Error(`Invalid link field: ${JSON.stringify(linkField)}`);
})
.join('\n');

const convertRoles = (roles: Record<string, EnrichedRoleField>, parentName: string, level: number): string =>
Object.entries(roles)
.map(([roleName, role]) => {
const fieldType =
role.cardinality === 'MANY'
? `array<record<${role.$things.map(sanitizeNameSurrealDB).join('|')}>>`
: `record<${role.$things.map(sanitizeNameSurrealDB).join('|')}>`;
const fieldDefinition = `${indent(level)}DEFINE FIELD ${roleName} ON TABLE ${parentName} TYPE option<${fieldType}>;`; //Todo: remove option when surrealDB transactions are smarter.
const roleEvent = generateRoleEvent(roleName, parentName, role, level);
return `${fieldDefinition}\n${roleEvent}`;
})
.join('\n');

const generateRoleEvent = (roleName: string, parentName: string, role: EnrichedRoleField, level: number): string => {
const eventName = `update_${roleName}`;

const targetRelationLinkField = role.playedBy?.find((lf) => lf.target === 'relation');
const targetRelationPath = targetRelationLinkField?.pathToRelation;
const firstTargetRoleLinkField = role.playedBy?.find((lf) => lf.target === 'role');
const firstTargetRolePath = firstTargetRoleLinkField?.pathToRelation;

const usedLinkField = targetRelationLinkField ?? firstTargetRoleLinkField;

if (!usedLinkField) {
throw new Error(`Invalid link field: ${JSON.stringify(role)}`);
}

const pathToRelation = sanitizeNameSurrealDB((targetRelationPath ?? firstTargetRolePath) as string);

const generateSet = (fields: { path: string; cardinality: 'ONE' | 'MANY' }[], action: 'remove' | 'add'): string => {
return fields
.map(({ path, cardinality }) => {
const operator =
action === 'remove' ? (cardinality === 'ONE' ? '=' : '-=') : cardinality === 'ONE' ? '=' : '+=';
const value = action === 'remove' ? (cardinality === 'ONE' ? 'NONE' : '$before.id') : '$after.id';
return `${path} ${operator} ${value}`;
})
.join(', ');
};

const impactedLinkFields =
role.impactedLinkFields?.map((lf) => ({
path: lf.path,
cardinality: lf.cardinality,
})) || [];

const directField = { path: pathToRelation, cardinality: usedLinkField.cardinality };
const allFields = [directField, ...impactedLinkFields];

const removalsSet = generateSet(allFields, 'remove');
const additionsSet = generateSet(allFields, 'add');

const cardOneEvents = `
IF ($before.${roleName}) THEN {UPDATE $before.${roleName} SET ${removalsSet}} END;
IF ($after.${roleName}) THEN {UPDATE $after.${roleName} SET ${additionsSet}} END;`;

const cardManyEvents = `
LET $edges = fn::get_mutated_edges($before.${roleName}, $after.${roleName});
FOR $unlink IN $edges.deletions {UPDATE $unlink SET ${removalsSet};};
FOR $link IN $edges.additions {${
usedLinkField.cardinality === 'ONE'
? `
IF ($link.${pathToRelation}) THEN {UPDATE $link.${pathToRelation} SET ${roleName} ${role.cardinality === 'ONE' ? '= NONE' : '-= $link.id'}} END;` //! This should probably be an independnt event on card one field, that it replaces old one by new one, instead of doing it from here
: ''
}
UPDATE $link SET ${additionsSet};
};`;

return indentPar(
`DEFINE EVENT ${eventName} ON TABLE ${parentName} WHEN $before.${roleName} != $after.${roleName} THEN {${role.cardinality === 'ONE' ? cardOneEvents : cardManyEvents}
};`,
level + 1,
);
};

const mapContentTypeToSurQL = (contentType: string, validations?: Validations): string => {
const typeMap: Record<string, string> = {
TEXT: 'string',
ID: 'string',
EMAIL: 'string',
NUMBER: 'number',
BOOLEAN: 'bool',
DATE: 'datetime',
JSON: 'object',
FLEX: 'bool|bytes|datetime|duration|geometry|number|object|string',
};

const format = (ct: string, value: unknown): any => {
switch (ct) {
case 'TEXT':
case 'ID':
case 'EMAIL':
return `"${value}"`;
case 'NUMBER':
case 'BOOLEAN':
return value;
case 'DATE':
return `d"${value}"`;
case 'FLEX':
return value;
default:
return value;
}
};

const type = validations?.enum
? `${validations.enum.map((value) => format(contentType, value)).join('|')}`
: typeMap[contentType];
if (!type) {
throw new Error(`Unknown content type: ${contentType}`);
}

if (validations?.required) {
return `${type}`;
}
return `option<${type}>`;
};

const addUtilityFunctions = (): string => `
-- BORM TOOLS
DEFINE FUNCTION fn::get_mutated_edges(
$before_relation: option<array|record>,
$after_relation: option<array|record>,
) {
LET $notEmptyCurrent = $before_relation ?? [];
LET $current = array::flatten([$notEmptyCurrent]);
LET $notEmptyResult = $after_relation ?? [];
LET $result = array::flatten([$notEmptyResult]);
LET $links = array::complement($result, $current);
LET $unlinks = array::complement($current, $result);
RETURN {
additions: $links,
deletions: $unlinks
};
};
DEFINE FUNCTION fn::as_array($var: option<array<record>|record>) {
RETURN (type::is::array($var) AND $var) OR [$var]
};
`;

export const defineSURQLSchema = (schema: EnrichedBormSchema): string => convertBQLToSurQL(schema);
Loading

0 comments on commit b49406e

Please sign in to comment.