diff --git a/package.json b/package.json index 84189d27..e86f6113 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ "test": "./tests/test.sh --coverage", "test:ignoreTodo": "pnpm test:surrealdb-ignoreTodo && pnpm test:typedb-ignoreTodo && pnpm test:multidb", "test:multidb": "./tests/test.sh multidb", - "test:query": "./tests/test.sh query.test.ts", - "test:surrealdb-ignoreTodo": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/test.sh tests/unit/allTests.test.ts -t \"^(?!.*TODO{.*[S].*}:).*\"", - "test:surrealdb-ignoreTodo:edges": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=edges ./tests/test.sh tests/unit -t \"^(?!.*TODO{.*[S].*}:).*\"", - "test:surrealdb-query:edges": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=edges ./tests/test.sh tests/unit/queries/query.test.ts", - "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:postgres-query": "cross-env BORM_TEST_ADAPTER=postgresDB ./tests/postgres.sh tests/unit/queries/query.test.ts -t \"^(?!.*TODO{.*[P].*}:).*\"", + "test:surrealdb-ignoreTodo": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/surreal.sh tests/unit/allTests.test.ts -t \"^(?!.*TODO{.*[S].*}:).*\"", + "test:surrealdb-ignoreTodo:edges": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=edges ./tests/surreal.sh tests/unit -t \"^(?!.*TODO{.*[S].*}:).*\"", + "test:surrealdb-query:edges": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=edges ./tests/surreal.sh tests/unit/queries/query.test.ts", + "test:surrealdb-query:refs": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/surreal.sh tests/unit/queries/query.test.ts", + "test:surrealdb-mutation:edges": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=edges ./tests/surreal.sh tests/unit/mutations", + "test:surrealdb-mutation:refs": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/surreal.sh tests/unit/mutations", + "test:surrealdb-schema": "cross-env BORM_TEST_ADAPTER=surrealDB BORM_TEST_SURREALDB_LINK_MODE=refs ./tests/surreal.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", @@ -67,6 +67,7 @@ "immer": "10.1.1", "nanoid": "^5.0.7", "object-traversal": "^1.0.1", + "pg": "^8.13.1", "radash": "^12.1.0", "robot3": "^0.4.1", "surrealdb": "^1.0.6", @@ -76,6 +77,7 @@ "devDependencies": { "@blitznocode/eslint-config": "^1.1.0", "@types/node": "^22.5.5", + "@types/pg": "^8.11.10", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^2.1.1", "cross-env": "^7.0.3", @@ -101,5 +103,6 @@ "homepage": "https://github.com/Blitzapps/blitz-orm#readme", "directories": { "test": "tests" - } + }, + "packageManager": "pnpm@8.10.2+sha512.0782093d5ba6c7ad9462081bc1ef0775016a4b4109eca1e1fedcea6f110143af5f50993db36c427d4fa8c62be3920a3224db12da719d246ca19dd9f18048c33c" } diff --git a/src/index.ts b/src/index.ts index bb49a206..776df18d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { enableMapSet } from 'immer'; import { runMutationMachine } from './stateMachine/mutation/mutationMachine'; import { runQueryMachine } from './stateMachine/query/queryMachine'; import { SimpleSurrealClient } from './adapters/surrealDB/client'; +import { Client } from 'pg'; export * from './types'; @@ -47,10 +48,20 @@ class BormClient { getDbHandles = () => this.dbHandles; init = async () => { - const dbHandles: AllDbHandles = { typeDB: new Map(), surrealDB: new Map() }; + const dbHandles: AllDbHandles = { typeDB: new Map(), surrealDB: new Map(), postgresDB: new Map() }; await Promise.all( this.config.dbConnectors.map(async (dbc) => { - if (dbc.provider === 'surrealDB') { + if (dbc.provider === 'postgresDB') { + const client = new Client({ + host: dbc.host, + port: dbc.port, + user: dbc.user, + password: dbc.password, + database: dbc.dbName, + }); + await client.connect(); + dbHandles.postgresDB.set(dbc.id, { client }); + } else if (dbc.provider === 'surrealDB') { const client = new SimpleSurrealClient({ url: dbc.url, username: dbc.username, @@ -67,8 +78,7 @@ class BormClient { // totalConnections: 8, // }); dbHandles.surrealDB.set(dbc.id, { client, providerConfig: dbc.providerConfig }); - } - if (dbc.provider === 'typeDB' && dbc.dbName) { + } else if (dbc.provider === 'typeDB' && dbc.dbName) { // const client = await TypeDB.coreClient(dbc.url); // const clientErr = undefined; const [clientErr, client] = await tryit(TypeDB.coreDriver)(dbc.url); @@ -89,8 +99,7 @@ class BormClient { }`; throw new Error(message); } - } - if (dbc.provider === 'typeDBCluster' && dbc.dbName) { + } else if (dbc.provider === 'typeDBCluster' && dbc.dbName) { const credential = new TypeDBCredential(dbc.username, dbc.password, dbc.tlsRootCAPath); const [clientErr, client] = await tryit(TypeDB.cloudDriver)(dbc.addresses, credential); diff --git a/src/stateMachine/query/pg/README.md b/src/stateMachine/query/pg/README.md new file mode 100644 index 00000000..d2551093 --- /dev/null +++ b/src/stateMachine/query/pg/README.md @@ -0,0 +1,324 @@ +# SQL syntax + +## Nested role + +The id fields of the joined table must be queried to identify whether the record exist or not. + +```sql +SELECT + "[table1]__[random]"."[column]" AS "[table1]__[random].[column]", + "[table2]__[random]"."[column]" AS "[table2]__[random].[column]", + "[table2]__[random]"."[primary_key_column]" AS "[table2]__[random].[primary_key_column]" +FROM "[table1]" AS "[table1]__[random]" +LEFT JOIN "[table2]" AS "[table2]_[random]" +ON "[table1]_[random]"."[column]" = "[table2]_[random]"."[column]" +WHERE "[table1]__[random]"."[column]" = $1 +``` + +## Nested link field + +When querying a nested link field the parent table's id must be queried: + +```sql +SELECT + "[table1]__[random]"."[id_column]" AS "[table1]__[random].[id_column]" +FROM "[table1]" AS "[table1]__[random]"; + +SELECT + "[table2]__[random]"."[column]" AS "[table1]__[random]"."[column]" +FROM "[table2]" AS "[table2]__[random]"; +WHERE "[table2]__[random]"."[foreign_key_column]" IN $1 +``` + +# Example + +## Schema + +### Postgres Schema + +```sql +CREATE TABLE users ( + id INT PRIMARY KEY, + name TEXT, + email TEXT +) + +CREATE TABLE projects ( + id INT PRIMARY KEY, + name TEXT +) + +CREATE TABLE posts ( + id INT PRIMARY KEY, + title TEXT, + content TEXT, + project_id INT REFERENCES projects (id) +) + +CREATE TABLE post_authors ( + id INT PRIMARY KEY, + user_id INT REFERENCES users (id), + post_id INT REFERENCES posts (id) +) +``` + +### BORM Schema + +```ts +const user = { + name: 'User', + defaultDbConnector: { id: 'pg', path: 'users' }, + idFields: ['id'], + dataFields: [ + { path: 'id', contentType: 'NUMBER' }, + { path: 'name', contentType: 'TEXT' }, + { path: 'email', contentType: 'TEXT' }, + ], + linkFields: [ + { path: 'postAuthors', relation: 'PostAuthor', plays: 'user' }, + { path: 'posts', relation: 'PostAuthor', plays: 'user', target: 'post' }, + ], + roles: {}, +}; + +const project = { + name: 'Project', + defaultDbConnector: { id: 'pg', path: 'projects' }, + idFields: ['id'], + dataFields: [ + { path: 'id', contentType: 'NUMBER' }, + { path: 'name', contentType: 'TEXT' }, + ], + linkFields: [ + { path: 'posts', relation: 'Post', plays: 'project' }, + ], + roles: {}, +}; + +const post = { + name: 'Post', + defaultDbConnector: { id: 'pg', path: 'posts' }, + idFields: ['id'], + dataFields: [ + { path: 'id', contentType: 'NUMBER' }, + { path: 'title', contentType: 'TEXT' }, + { path: 'content', contentType: 'TEXT' }, + { path: 'projectId', dbPath: 'project_id', contentType: 'NUMBER' }, + ], + linkFields: [ + { path: 'postAuthors', relation: 'PostAuthor', plays: 'post' }, + { path: 'authors', relation: 'PostAuthor', plays: 'post', target: 'author' }, + ], + roles: { + project: { fields: ['projectId'] }, + }, +}; + +const postAuthor = { + name: 'PostAuthor', + defaultDbConnector: { id: 'pg', path: 'post_authors' }, + idFields: ['id'], + dataFields: [ + { path: 'id', contentType: 'NUMBER' }, + // { path: 'userId', dbPath: 'user_id', contentType: 'NUMBER' }, + // { path: 'postId', dbPath: 'post_id', contentType: 'NUMBER' }, + ], + linkFields: [], + roles: { + // user: { dbPath: ['user_id'] }, + // post: { dbPath: ['post_id'] }, + user: { dbConfig: { db: 'pg', fields: [{ path: 'user_id', type: 'INT' }] } }, + post: { dbConfig: { db: 'pg', fields: [{ path: 'post_id', type: 'INT' }] } }, + // user: { fields: ['userId'] }, + // post: { fields: ['postId'] }, + }, +}; +``` + +Notes: +- A link field has cardinality MANY if the role it plays is not unique. +- Roles always have cardinality ONE. + +What's missing from the existing schema: +- `role.fields` that points to a data field. + +What's different from the existing schema: +- `linkField.target` is the name of the opposite role or undefined. If it's undefined, the value is the relation itself. If it's defined, the value is the entity/relation that plays that role. + +## Query + +### Query all fields + +```ts +{ + $thing: 'Post', + $filter: { projectId: 1 }, +} +``` + +```sql +SELECT + id, + title, + content, + project_id +FROM posts +WHERE project_id = $1; +``` + +```ts +type Data = { + id: number; + title: string; + content: string; + projectId: number; +}[]; +``` + +### Query nested link field relation + +```ts +{ + $thing: 'Post', + $fields: [ + 'id', + 'title', + 'content', + { + $path: 'postAuthors', + $fields: [ + { $path: 'user', $fields: ['id', 'name'] }, + ], + } + ], + $filter: { id: 1 }, +} +``` + +```sql +SELECT + id, + title, + content +FROM posts +WHERE id IN ($1); + +SELECT + post_authors__random123.user_id AS "post_authors__random123.user_id", + users__random456.id AS "users__random456.id", + users__random456.name AS "users__random456.name" +FROM post_authors AS post_authors__random123 +LEFT JOIN users AS users__random456 +ON post_authors__random123.user_id = users__random456.id +WHERE post_authors__random123.id IN ($1); +``` + +```ts +type Data = { + id: number; + title: string; + content: string; + postAuthors: { + user: { + id: number; + name: string; + }; + }[]; +}; +``` + +```sql +SELECT +FROM a +LEFT JOIN b +ON a.id = b.id +LIMIT = 1 +``` + +### Nested role relation + +```ts +{ + $thing: 'Post', + $fields: [ + 'id', + 'title', + 'content', + { + $path: 'project', + $fields: ['id', 'name'], + } + ], + $filter: { id: 1 }, +} +``` + +```sql +SELECT + "posts__random123"."id" AS "id", + "posts__random123"."title" AS "title", + "posts__random123"."content" AS "content", + "projects__random456"."id" AS "projects__random456.id", + "projects__random456"."name" AS "projects__random456.name" +FROM "posts" AS "posts__random123" +LEFT JOIN "projects" AS "projects__random456" +ON "posts__random123"."project_id" = "projects__random456.id" +WHERE "posts"."id" = $1 +``` + +```ts +type Data = { + id: number, + title: string, + content: string, + project: { + id: number; + name: string; + } +}; +``` + +### Nested (tunneled) many-to-many relation + +```ts +{ + $thing: 'Post', + $fields: [ + 'id', + 'title', + 'content', + { + $path: 'authors', + $fields: ['id', 'name'], + } + ], +} +``` + +```sql +SELECT + "posts__123"."id" AS "posts__123.id", + "posts__123"."title" AS "posts__123.title", + "posts__123"."content" AS "posts__123.content" +FROM "posts" AS "posts__123" + +SELECT + "users__456"."id", + "users__456"."name", +FROM "post_authors" AS "post_authors__123" +LEFT JOIN "users" AS "users__456" +ON "post_authors__123"."author_id" = "users__456"."id" +WHERE "post_authors__123"."id" = $1 +``` + +```ts +type Data = { + id: string; + title: string; + content: string; + authors: { + id: string; + name: string; + }[]; +}; +``` diff --git a/src/stateMachine/query/pg/build.ts b/src/stateMachine/query/pg/build.ts new file mode 100644 index 00000000..f7ec32f5 --- /dev/null +++ b/src/stateMachine/query/pg/build.ts @@ -0,0 +1,665 @@ +import { uid } from 'radash'; +import type { + EnrichedAttributeQuery, + EnrichedBQLQuery, + EnrichedBormEntity, + EnrichedBormRelation, + EnrichedBormSchema, + EnrichedDataField, + EnrichedFieldQuery, + EnrichedLinkQuery, + EnrichedRoleField, + EnrichedRoleQuery, +} from '../../../types'; +import { FieldSchema, QueryPath } from '../../../types/symbols'; + +export interface Q { + primaryKeys: { column: string; as: string }[]; + fields: { key: string; field: DataFieldQ | RoleFieldQ }[]; + subQueries?: { key: string; query: LinkFieldQ }[]; + from: string; + alias: string; + where?: string; + params?: QueryParams; + orderBy?: { column: string; desc: boolean }[]; + limit?: number; + offset?: number; + unique: boolean; + thingType: 'relation' | 'entity'; + thing: string; + queryPath: string; +} + +export type FieldQ = DataFieldQ | RoleFieldQ | LinkFieldQ; + +export interface DataFieldQ { + type: 'data'; + column: string; + as: string; +} + +export type RoleFieldQ = RoleFieldIdQ | RoleFieldRecordQ; + +export interface RoleFieldIdQ { + type: 'role'; + primaryKeys: { column: string; as: string }[]; + join: string; + alias: string; + params?: QueryParams; + unique: boolean; + thingType: 'relation' | 'entity'; + thing: string; + queryPath: string; +} + +export interface RoleFieldRecordQ extends RoleFieldIdQ { + fields: { key: string; field: DataFieldQ | RoleFieldQ }[]; + subQueries?: { key: string; query: LinkFieldQ }[]; +} + +export type LinkFieldQ = LinkFieldIdQ | LinkFieldRecordQ | PassthroughLinkFieldQ; + +export interface LinkFieldIdQ { + type: 'link'; + primaryKeys: { column: string; as: string }[]; + /** + * Used to join the parent records + */ + foreignKeys: { + column: string; + as: string; + }[]; + from: string; + alias: string; + where: string; + params?: QueryParams; + unique: boolean; + thingType: 'relation' | 'entity'; + thing: string; + queryPath: string; +} + +export interface LinkFieldRecordQ extends LinkFieldIdQ { + fields: { key: string; field: DataFieldQ | RoleFieldQ }[]; + subQueries?: { key: string; query: LinkFieldQ }[]; +} + +export interface PassthroughLinkFieldQ extends LinkFieldIdQ { + tunnel: Omit | Omit; +} + +export type QueryParams = { key: string; value: any }[]; + +type Filter = LocalFilter & FilterSet; + +interface LocalFilter { + [field: string]: FieldFilter; +} + +interface FieldFilter { + $eq?: Value; + $neq?: Value; + $in?: string[] | number[] | boolean[] | Date[]; + $exists?: boolean; + $id?: string | string[]; +} + +interface FilterSet { + $not?: LocalFilter; + $and?: LocalFilter; + $or?: LocalFilter; +} + +type Value = string | number | boolean | Date | null; + +export const build = (props: { queries: EnrichedBQLQuery[]; schema: EnrichedBormSchema }) => { + const { queries, schema } = props; + return queries.map((query) => buildQ({ query, schema })); +}; + +const buildQ = (params: { query: EnrichedBQLQuery; schema: EnrichedBormSchema }): Q => { + const { query, schema } = params; + + const relation = schema.entities[query.$thing] ?? schema.relations[query.$thing]; + + if (!relation) { + throw new Error(`Entity or relation ${query.$thing} does not exist`); + } + + const table = relation.defaultDBConnector.path ?? relation.name; + const tableAlias = createTableAlias(table); + const dataFieldMap = Object.fromEntries(relation.dataFields?.map((i) => [i.path, i]) ?? []); + const fieldMap: Record = {}; + const subQueryMap: Record = {}; + // @ts-expect-error Filter type does not match + const condition = buildCondition({ table: tableAlias, filter: query.$filter, thing: relation, id: query.$id }); + + query.$fields.forEach((i) => { + const q = buildField({ query: i, schema, table: tableAlias }); + if (q.type === 'link') { + subQueryMap[i.$as] = q; + } else { + fieldMap[i.$as] = q; + } + }); + + const primaryKeys = relation.idFields.map((i) => { + const df = dataFieldMap[i]; + if (!df) { + throw new Error(`Data field ${i} does not exist in ${relation.name}`); + } + return { column: `"${tableAlias}"."${df.path}"`, as: `${tableAlias}.${df.path}` }; + }); + + if (primaryKeys.length === 0) { + throw new Error(`Table ${table} does not have a primary key`); + } + + const fields = Object.entries(fieldMap).map(([key, field]) => ({ key, field })); + const subQueries = Object.entries(subQueryMap).map(([key, query]) => ({ key, query })); + const orderBy = query.$sort?.flatMap((i) => { + const [field, desc] = typeof i === 'string' ? [i, false] : [i.field, i.desc ?? false]; + const column = dataFieldMap[field]?.path; + if (!column) { + return []; + } + return [{ column, desc }]; + }); + + return { + primaryKeys, + fields, + from: `"${table}" AS "${tableAlias}"`, + alias: tableAlias, + where: condition?.condition, + subQueries: subQueries && subQueries.length !== 0 ? subQueries : undefined, + params: condition?.params, + orderBy, + limit: query.$limit, + offset: query.$offset, + unique: query.$filterByUnique, + thing: relation.name, + thingType: relation.thingType, + queryPath: query[QueryPath], + }; +}; + +const buildField = (params: { query: EnrichedFieldQuery; schema: EnrichedBormSchema; table: string }): FieldQ => { + const { query, schema, table } = params; + + switch (query.$fieldType) { + case 'data': { + return buildDataFieldQuery({ query, table }); + } + case 'role': { + return buildRoleFieldQuery({ query, schema, table }); + } + case 'link': { + return buildLinkFieldQuery({ query, schema }); + } + } +}; + +const buildDataFieldQuery = (params: { query: EnrichedAttributeQuery; table: string }): DataFieldQ => { + const { query, table } = params; + + const dataField = query[FieldSchema]; + + return { + type: 'data', + column: `"${table}"."${dataField.path}"`, + as: `${table}.${dataField.path}`, + }; +}; + +const buildRoleFieldQuery = (params: { + query: EnrichedRoleQuery; + schema: EnrichedBormSchema; + table: string; +}): RoleFieldQ => { + const { query, schema, table } = params; + const role = query[FieldSchema]; + + return { + ..._buildRoleFieldQuery({ + role, + $fields: query.$justId ? undefined : query.$fields, + // @ts-expect-error Filter type does not match + $filter: query.$filter, + thing: query.$playedBy.thing, + schema, + table, + queryPath: query[QueryPath], + }), + unique: query.$filterByUnique || role.cardinality === 'ONE', + }; +}; + +const _buildRoleFieldQuery = (params: { + role: EnrichedRoleField; + thing: string; + $id?: string | string[]; + $fields?: EnrichedFieldQuery[]; + $filter?: Filter; + schema: EnrichedBormSchema; + table: string; + queryPath: string; +}): Omit | Omit => { + const { $fields, $filter, $id, role, schema, thing, table, queryPath } = params; + + if (!role.dbConfig) { + throw new Error('role.dbConfig is missing'); + } + + const player = schema.entities[thing] ?? schema.relations[thing]; + + if (!player) { + throw new Error(`Entity or relation ${thing} does not exist`); + } + + const primaryKeys = player.idFields.map((i) => { + const df = player.dataFields?.find((f) => f.path === i); + if (!df) { + throw new Error(`Data field ${i} does not exist in ${player.name}`); + } + return df.path; + }); + + const foreignKeys = role.dbConfig.fields.map((i) => i.path); + + if (foreignKeys.length !== primaryKeys.length) { + throw new Error(`Role ${role.path}`); + } + + const joinTable = player.defaultDBConnector.path ?? player.name; + const joinTableAlias = createTableAlias(joinTable); + const on = primaryKeys.map((c, i) => { + return `"${table}"."${foreignKeys[i]}" = "${joinTableAlias}"."${c}"`; + }); + + const fieldMap: Record = {}; + const subQueryMap: Record = {}; + const condition = buildCondition({ table: joinTableAlias, filter: $filter, thing: player, id: $id }); + const joinCondition = condition ? ` AND ${condition.condition}` : ''; + + if (!$fields) { + return { + type: 'role', + alias: joinTableAlias, + primaryKeys: primaryKeys.map((c) => ({ column: `"${joinTableAlias}"."${c}"`, as: `${joinTableAlias}.${c}` })), + join: `LEFT JOIN "${joinTable}" AS "${joinTableAlias}" ON ${on.join(' AND ')}${joinCondition}`, + params: condition?.params, + thing: player.name, + thingType: player.thingType, + queryPath, + }; + } + + $fields.forEach((i) => { + const q = buildField({ query: i, schema, table: joinTableAlias }); + if (q.type === 'link') { + subQueryMap[i.$as] = q; + } else { + fieldMap[i.$as] = q; + } + }); + + const fields = Object.entries(fieldMap).map(([key, field]) => ({ key, field })); + const subQueries = Object.entries(subQueryMap).map(([key, query]) => ({ key, query })); + + return { + type: 'role', + alias: joinTableAlias, + fields, + primaryKeys: primaryKeys.map((c) => ({ column: `"${joinTableAlias}"."${c}"`, as: `${joinTableAlias}.${c}` })), + join: `LEFT JOIN "${joinTable}" AS "${joinTableAlias}" ON ${on.join(' AND ')}${joinCondition}`, + params: condition?.params, + subQueries: subQueries.length !== 0 ? subQueries : undefined, + thing: player.name, + thingType: player.thingType, + queryPath, + }; +}; + +const buildLinkFieldQuery = (params: { query: EnrichedLinkQuery; schema: EnrichedBormSchema }): LinkFieldQ => { + const { query, schema } = params; + + const linkField = query[FieldSchema]; + const relation = schema.relations[linkField.relation]; + + if (!relation) { + throw new Error(`Relation ${linkField.relation} does not exist`); + } + + const table = relation.defaultDBConnector.path ?? relation.name; + const tableAlias = createTableAlias(table); + const unique = query.$filterByUnique || linkField.cardinality === 'ONE'; + const role = relation.roles[linkField.plays]; + + if (!role) { + throw new Error(`Role ${query.$playedBy.relation}.${query.$playedBy.plays} does not exist`); + } + if (role.dbConfig?.db !== 'postgres') { + throw new Error(); + } + if (role.dbConfig.fields.length === 0) { + throw new Error(); + } + + const primaryKeys = relation.idFields.map((i) => { + const df = relation.dataFields?.find((f) => f.path === i); + if (!df) { + throw new Error(`Data field ${i} does not exist in ${relation.name}`); + } + return { column: `"${tableAlias}"."${df.path}"`, as: `${tableAlias}.${df.path}` }; + }); + + if (primaryKeys.length === 0) { + throw new Error(`Table ${table} does not have a primary key`); + } + + const foreignKeys = role.dbConfig.fields.map((f) => ({ + column: `"${tableAlias}"."${f.path}"`, + as: `${tableAlias}.${f.path}`, + })); + + const condition = + linkField.target !== 'role' + ? // @ts-expect-error Filter type does not match + buildCondition({ table: tableAlias, filter: query.$filter, thing: relation, id: query.$id }) + : undefined; + const _condition = condition ? ` AND ${condition.condition}` : ''; + + let where: string; + + if (foreignKeys.length === 1) { + where = `${foreignKeys[0].column} = ANY($1)${_condition}`; + } else { + // TODO: Postgres does not support multiple anonymous unnest. + where = `(${foreignKeys.map((i) => i.column).join(', ')}) IN (SELECT ${foreignKeys.map((_, i) => `UNNEST($${i + 1})`)})${_condition}`; + } + + if (linkField.target === 'role') { + if (linkField.oppositeLinkFieldsPlayedBy.length > 1) { + throw new Error( + `Link field with multiple role target players is not supported: ${linkField.relation}.${linkField.plays}`, + ); + } + const [oppositePlayer] = linkField.oppositeLinkFieldsPlayedBy; + if (!oppositePlayer) { + throw new Error(`Role ${relation.name}.${linkField.plays} does not have opposite player`); + } + const role = relation.roles[oppositePlayer.plays]; + if (!role) { + throw new Error(`Role ${relation.name}.${oppositePlayer.plays} does not exist`); + } + return { + type: 'link', + primaryKeys, + tunnel: _buildRoleFieldQuery({ + $fields: query.$justId ? undefined : query.$fields, + $id: query.$id, + // @ts-expect-error Filter type does not match + $filter: query.$filter, + role, + thing: query.$playedBy.thing, + schema, + table: tableAlias, + queryPath: query[QueryPath], + }), + foreignKeys, + from: `"${table}" AS "${tableAlias}"`, + alias: tableAlias, + where, + params: condition?.params, + unique, + thing: oppositePlayer.thing, + thingType: oppositePlayer.thingType, + queryPath: query[QueryPath], + }; + } + + const fieldMap: Record = {}; + const subQueryMap: Record = {}; + + query.$fields.forEach((i) => { + const q = buildField({ query: i, schema, table: tableAlias }); + if (q.type === 'link') { + subQueryMap[i.$as] = q; + } else { + fieldMap[i.$as] = q; + } + }); + + const fields = Object.entries(fieldMap).map(([key, field]) => ({ key, field })); + const subQueries = Object.entries(subQueryMap).map(([key, query]) => ({ key, query })); + + if (query.$justId) { + return { + type: 'link', + primaryKeys, + foreignKeys, + from: `"${table}" AS "${tableAlias}"`, + alias: tableAlias, + where, + params: condition?.params, + unique, + thing: relation.name, + thingType: relation.thingType, + queryPath: query[QueryPath], + }; + } + + if (subQueries.length === 0) { + return { + type: 'link', + primaryKeys, + fields, + foreignKeys, + from: `"${table}" AS "${tableAlias}"`, + alias: tableAlias, + where, + params: condition?.params, + unique, + thing: relation.name, + thingType: relation.thingType, + queryPath: query[QueryPath], + }; + } + + return { + type: 'link', + fields, + foreignKeys, + from: `"${table}" AS "${tableAlias}"`, + alias: tableAlias, + where, + primaryKeys, + subQueries, + params: condition?.params, + unique, + thing: relation.name, + thingType: relation.thingType, + queryPath: query[QueryPath], + }; +}; + +const createTableAlias = (table: string) => { + return `${table}__${uid(6)}`; +}; + +const buildCondition = (params: { + table: string; + filter?: Filter; + thing: EnrichedBormEntity | EnrichedBormRelation; + id?: string | number | string[] | number[]; +}) => { + const { table, filter, thing, id } = params; + const conditions: string[] = []; + const queryParams: { key: string; value: any }[] = []; + + if (id) { + if (thing.idFields.length > 1) { + throw new Error('Filtering entity/relation with composite id fields is not supported'); + } + const [idField] = thing.idFields + .map((i) => thing.dataFields?.find((j) => j.path === i)) + .filter((i): i is EnrichedDataField => !!i); + if (!idField) { + throw new Error(`Missing id field ${thing}`); + } + const key = uid(6); + if (Array.isArray(id)) { + conditions.push(`"${table}"."${idField.path}" = ANY($${key})`); + } else { + conditions.push(`"${table}"."${idField.path}" = $${key}`); + } + queryParams.push({ key, value: id }); + } + + const { $and, $or, $not, $id: _$id, ...rest } = filter ?? {}; + + if ($and) { + const res = buildLocalCondition({ table, filter: $and, thing }); + if (res.conditions.length === 1) { + conditions.push(res.conditions[0]); + } else if (res.conditions.length > 1) { + conditions.push(`(${res.conditions.join(' AND ')})`); + } + queryParams.push(...res.params); + } + + if ($or) { + const res = buildLocalCondition({ table, filter: $or, thing }); + if (res.conditions.length === 1) { + conditions.push(res.conditions[0]); + } else if (res.conditions.length > 1) { + conditions.push(`(${res.conditions.join(' OR ')})`); + } + queryParams.push(...res.params); + } + + if ($not) { + const res = buildLocalCondition({ table, filter: $not, thing }); + conditions.push(`NOT (${res.conditions.join(' AND ')})`); + queryParams.push(...res.params); + } + + const res = buildLocalCondition({ table, filter: rest, thing }); + conditions.push(...res.conditions); + queryParams.push(...res.params); + + if (conditions.length === 0) { + return; + } + + return { + condition: conditions.length === 1 ? conditions[0] : `(${conditions.join(' AND ')})`, + params: queryParams, + }; +}; + +const buildLocalCondition = (params: { + table: string; + filter: LocalFilter; + thing: EnrichedBormEntity | EnrichedBormRelation; +}) => { + const { table, filter, thing } = params; + const conditions: string[] = []; + const queryParams: { key: string; value: any }[] = []; + + Object.entries(filter).forEach(([k, v]) => { + const condition = buildFieldCondition({ table, field: k, filter: v, thing }); + if (condition) { + conditions.push(condition.condition); + queryParams.push(...condition.params); + } + }); + + return { + conditions, + params: queryParams, + }; +}; + +const buildFieldCondition = (params: { + table: string; + field: string; + filter: FieldFilter; + thing: EnrichedBormEntity | EnrichedBormRelation; +}) => { + const { table, field, filter, thing } = params; + const conditions: string[] = []; + const queryParams: { key: string; value: any }[] = []; + const { $eq, $neq, $in, $exists, $id } = filter; + + const dataField = thing.dataFields?.find((i) => i.path === field); + const roleField = 'roles' in thing ? thing.roles[field] : undefined; + + if (dataField) { + if ($eq !== undefined) { + if ($eq === null) { + conditions.push(`("${table}"."${field}") IS NULL`); + } else { + const key = uid(6); + conditions.push(`"${table}"."${field}" = $${key}`); + queryParams.push({ key, value: $eq }); + } + } + + if ($neq !== undefined) { + if ($neq === null) { + conditions.push(`("${table}"."${field}") IS NOT NULL`); + } else { + const key = uid(6); + conditions.push(`"${table}"."${field}" != $${key}`); + queryParams.push({ key, value: $neq }); + } + } + + if ($in) { + const key = uid(6); + conditions.push(`"${table}"."${field}" = ANY($${key})`); + queryParams.push({ key, value: $in }); + } + + if ($exists !== undefined) { + const not = $exists ? ' NOT' : ''; + conditions.push(`("${table}"."${field}") IS${not} NULL`); + } + } else if (roleField) { + const [field] = roleField.dbConfig?.fields ?? []; + if (!field) { + throw new Error(`Role ${thing.name}.${roleField.path} does not have a foreign key`); + } + if (roleField.dbConfig && roleField.dbConfig.fields.length > 1) { + throw new Error( + `Filtering by a role field with multiple foreign keys is not supported: ${thing.name}.${roleField.path}`, + ); + } + if ($id !== undefined) { + if ($id === null) { + conditions.push(`("${table}"."${field.path}") IS NULL`); + } else if (Array.isArray($id)) { + const key = uid(6); + conditions.push(`"${table}"."${field.path}" = ANY($${key})`); + queryParams.push({ key, value: $id }); + } else { + const key = uid(6); + conditions.push(`"${table}"."${field.path}" = $${key}`); + queryParams.push({ key, value: $id }); + } + } + } else { + throw new Error(`Field ${field} does not exist in ${thing.name}`); + } + + if (conditions.length === 0) { + return; + } + + return { + condition: conditions.length === 1 ? conditions[0] : `(${conditions.join(' AND ')})`, + params: queryParams, + }; +}; diff --git a/src/stateMachine/query/pg/machine.ts b/src/stateMachine/query/pg/machine.ts new file mode 100644 index 00000000..872d13b9 --- /dev/null +++ b/src/stateMachine/query/pg/machine.ts @@ -0,0 +1,123 @@ +import type { BormConfig, EnrichedBQLQuery, EnrichedBormSchema } from '../../../types'; +import { createMachine, interpret, invoke, reduce, state, transition } from '../../robot3'; +import type { Q } from './build'; +import { build } from './build'; +import { run } from './run'; +import { assertDefined } from '../../../helpers'; +import type { Client } from 'pg'; + +export type PostgresDbMachineContext = { + bql: { + queries: EnrichedBQLQuery[]; + res?: any[]; + }; + postgres: { + queries?: Q[]; + res?: any[]; + }; + schema: EnrichedBormSchema; + config: BormConfig; + client: Client; + error?: string | null; +}; + +const errorTransition = transition( + 'error', + 'error', + reduce((ctx: PostgresDbMachineContext, event: any): PostgresDbMachineContext => { + return { + ...ctx, + error: event.error, + }; + }), +); + +const postgresDbQueryMachine = createMachine( + 'build', + { + build: invoke( + async (ctx: PostgresDbMachineContext) => { + return build({ queries: ctx.bql.queries, schema: ctx.schema }); + }, + transition( + 'done', + 'run', + reduce( + (ctx: PostgresDbMachineContext, event: any): PostgresDbMachineContext => ({ + ...ctx, + postgres: { + queries: event.data, + }, + }), + ), + ), + errorTransition, + ), + run: invoke( + async (ctx: PostgresDbMachineContext) => { + return run({ + client: ctx.client, + queries: assertDefined(ctx.postgres.queries), + metadata: !ctx.config.query?.noMetadata, + }); + }, + transition( + 'done', + 'success', + reduce( + (ctx: PostgresDbMachineContext, event: any): PostgresDbMachineContext => ({ + ...ctx, + postgres: { + ...ctx.postgres, + res: event.data, + }, + bql: { + ...ctx.bql, + res: event.data, + }, + }), + ), + ), + errorTransition, + ), + success: state(), + error: state(), + }, + (ctx: PostgresDbMachineContext) => ctx, +); + +const awaitQueryMachine = async (context: PostgresDbMachineContext) => { + return new Promise((resolve, reject) => { + interpret( + postgresDbQueryMachine, + (service) => { + if (service.machine.state.name === 'success') { + //@ts-expect-error = todo + resolve(service.context.bql.res); + } + if (service.machine.state.name === 'error') { + reject(service.context.error); + } + }, + context, + ); + }); +}; + +export const runPostgresSurrealDbQueryMachine = async ( + enrichedBql: EnrichedBQLQuery[], + schema: EnrichedBormSchema, + config: BormConfig, + client: Client, +) => { + return awaitQueryMachine({ + bql: { + queries: enrichedBql, + }, + postgres: {}, + schema: schema, + config: config, + client, + error: null, + }); +}; diff --git a/src/stateMachine/query/pg/run.ts b/src/stateMachine/query/pg/run.ts new file mode 100644 index 00000000..d2e58ce7 --- /dev/null +++ b/src/stateMachine/query/pg/run.ts @@ -0,0 +1,484 @@ +import { QueryPath } from '../../../types/symbols'; +import type { DataFieldQ, LinkFieldQ, QueryParams, Q, RoleFieldQ, RoleFieldIdQ, RoleFieldRecordQ } from './build'; +import type { Client } from 'pg'; + +export const run = async (params: { queries: Q[]; client: Client; metadata: boolean }) => { + const { queries, client, metadata } = params; + const res = await Promise.all(queries.map((i) => _run({ query: i, client, metadata }))); + return res; +}; + +const _run = async (params: { query: Q; client: Client; metadata: boolean }) => { + const { query, client, metadata } = params; + const { primaryKeys, subQueries, thing, thingType, queryPath } = query; + const fields = query.fields.map((i) => i.field); + + if (metadata) { + query.primaryKeys.forEach((i) => { + fields.push({ type: 'data', ...i }); + }); + } + + const { selects, joins, params: queryParams } = sqlFields(fields); + const select = `SELECT\n${selects.join(',\n')}`; + const from = `\nFROM ${query.from}`; + const join = joins.length !== 0 ? `\n${joins.join('\n')}` : ''; + const where = query.where ? `\nWHERE ${query.where}` : ''; + const orderBy = + query.orderBy && query.orderBy.length !== 0 + ? `\nORDER BY ${query.orderBy.map((i) => `"${query.alias}"."${i.column}" ${i.desc ? 'DESC' : 'ASC'}`).join(', ')}` + : ''; + const limit = typeof query.limit === 'number' ? `\nLIMIT ${query.limit}` : ''; + const offset = typeof query.offset === 'number' ? `\nOFFSET ${query.offset}` : ''; + const sql = `${select}${from}${join}${where}${orderBy}${limit}${offset}`; + const _queryParams = query.params ? [...query.params, ...queryParams] : queryParams; + const _sql = useOrdinalPlaceholder(sql, _queryParams); + const res = await client.query( + _sql, + _queryParams.map((i) => i.value), + ); + + let subQueryMap: SubQueryMap = {}; + const records: Record[] = []; + + res.rows.forEach((r) => { + const a = buildRecord({ + fields: query.fields, + subQueries, + primaryKeys, + data: r, + thing, + thingType, + metadata, + queryPath, + }); + records.push(a.record); + a.subQueries.forEach((i) => { + const s = subQueryMap[i.query.alias]; + if (s) { + s.records.push({ record: i.record, primaryKeys: i.primaryKeys, stringifiedKeys: i.stringifiedKeys }); + } else { + subQueryMap[i.query.alias] = { + key: i.key, + query: i.query, + records: [{ record: i.record, primaryKeys: i.primaryKeys, stringifiedKeys: i.stringifiedKeys }], + }; + } + }); + }); + + // eslint-disable-next-line no-constant-condition + while (true) { + const subQueries = Object.values(subQueryMap); + subQueryMap = {}; + if (subQueries.length === 0) { + break; + } + const a = await Promise.all(subQueries.map((subQuery) => runSubQuery({ subQuery, client, metadata }))); + a.forEach((i) => { + if (i) { + Object.entries(i).forEach(([k, v]) => { + subQueryMap[k] = v; + }); + } + }); + } + + if (query.unique) { + return records[0] ?? null; + } + + return records.length !== 0 ? records : null; +}; + +type SubQueryMap = Record< + string, // table alias + SubQuery +>; + +interface SubQuery { + key: string; + query: LinkFieldQ; + records: { + record: Record; // Reference the original record + primaryKeys: (string | number)[]; + stringifiedKeys: string; + }[]; +} + +interface SingleRecordSubQuery { + key: string; + query: LinkFieldQ; + record: Record; // Reference the original record + primaryKeys: (string | number)[]; + stringifiedKeys: string; +} + +const runSubQuery = async (params: { subQuery: SubQuery; client: Client; metadata: boolean }) => { + const { subQuery, client, metadata } = params; + const { key, query, records } = subQuery; + const { foreignKeys, unique } = query; + let fields: (DataFieldQ | Omit)[]; + + if ('tunnel' in query) { + fields = [query.tunnel]; + } else if ('fields' in query) { + fields = query.fields.map((i) => i.field); + if (metadata) { + query.primaryKeys.forEach((i) => fields.push({ type: 'data', ...i })); + } + } else { + fields = query.primaryKeys.map((i) => ({ ...i, type: 'data' })); + } + + subQuery.query.foreignKeys.forEach((fk) => { + fields.push({ type: 'data', ...fk }); + }); + + const { selects, joins, params: queryParams } = sqlFields(fields); + const select = `SELECT\n${selects.join(',\n')}`; + const from = `\nFROM ${subQuery.query.from}`; + const join = joins.length !== 0 ? `\n${joins.join('\n')}` : ''; + const where = subQuery.query.where ? `\nWHERE ${subQuery.query.where}` : ''; + const sql = `${select}${from}${join}${where}`; + const foreignKeysParams = subQuery.query.foreignKeys.map((_, i) => subQuery.records.map((r) => r.primaryKeys[i])); + const _queryParams = subQuery.query.params ? [...subQuery.query.params, ...queryParams] : queryParams; + const _sql = useOrdinalPlaceholder(sql, _queryParams, foreignKeysParams.length + 1); + const res = await client.query(_sql, [...foreignKeysParams, ..._queryParams.map((i) => i.value)]); + const { rows } = res; + + if ('tunnel' in query) { + if ('fields' in query.tunnel) { + const { fields, subQueries, primaryKeys, thing, thingType, queryPath } = query.tunnel; + return buildSubQueriedRecords({ + records, + subQueries, + primaryKeys, + foreignKeys, + fields, + unique, + key, + rows, + requirePrimaryKeys: true, + metadata, + thing, + thingType, + queryPath, + }); + } else { + return buildSubQueriedId({ + records, + primaryKeys: query.tunnel.primaryKeys, + foreignKeys, + unique, + key, + rows, + }); + } + } else if ('fields' in query) { + return buildSubQueriedRecords({ + records, + subQueries: query.subQueries, + primaryKeys: query.primaryKeys, + foreignKeys, + fields: query.fields, + unique, + key, + rows, + requirePrimaryKeys: false, + metadata, + thing: query.thing, + thingType: query.thingType, + queryPath: query.queryPath, + }); + } else { + return buildSubQueriedId({ + records, + primaryKeys: query.primaryKeys, + foreignKeys, + unique, + key, + rows, + }); + } +}; + +const buildSubQueriedRecords = (params: { + key: string; + records: Record[]; + fields: { key: string; field: DataFieldQ | RoleFieldQ }[]; + subQueries?: { key: string; query: LinkFieldQ }[]; + unique: boolean; + primaryKeys: { column: string; as: string }[]; + foreignKeys: { column: string; as: string }[]; + rows: any[]; + requirePrimaryKeys: boolean; + metadata: boolean; + thing: string; + thingType: 'entity' | 'relation'; + queryPath: string; +}) => { + const { + fields, + subQueries, + primaryKeys, + foreignKeys, + records, + key, + unique, + rows, + requirePrimaryKeys, + thing, + thingType, + metadata, + queryPath, + } = params; + const subQueryMap: SubQueryMap = {}; + const subRecords: Record[]> = {}; + + rows.forEach((r) => { + if (requirePrimaryKeys) { + const exists = primaryKeys.every((k) => r[k.as] !== null && r[k.as] !== undefined); + if (!exists) { + return; + } + } + const partialRecord = buildRecord({ + fields, + subQueries, + primaryKeys, + data: r, + thing, + thingType, + metadata, + queryPath, + }); + const fk: (string | number)[] = []; + foreignKeys.forEach((i) => { + const v = r[i.as]; + fk.push(v); + }); + const stringifiedKeys = stringifyKeys(fk); + const rs = subRecords[stringifiedKeys] || []; + rs.push(partialRecord.record); + subRecords[stringifiedKeys] = rs; + partialRecord.subQueries.forEach((i) => { + const s = subQueryMap[i.key]; + if (s) { + s.records.push({ record: i.record, primaryKeys: i.primaryKeys, stringifiedKeys: i.stringifiedKeys }); + } else { + subQueryMap[i.key] = { + key: i.key, + query: i.query, + records: [{ record: i.record, primaryKeys: i.primaryKeys, stringifiedKeys: i.stringifiedKeys }], + }; + } + }); + }); + + if (unique) { + records.forEach(({ record, stringifiedKeys }) => { + const [sub] = subRecords[stringifiedKeys] || []; + // eslint-disable-next-line no-param-reassign + record[key] = sub ?? null; + }); + } else { + records.forEach(({ record, stringifiedKeys }) => { + const sub = subRecords[stringifiedKeys]; + // eslint-disable-next-line no-param-reassign + record[key] = sub && sub.length !== 0 ? sub : null; + }); + } + + return subQueryMap; +}; + +const buildSubQueriedId = (params: { + key: string; + records: Record[]; + unique: boolean; + primaryKeys: { column: string; as: string }[]; + foreignKeys: { column: string; as: string }[]; + rows: any[]; +}) => { + const { key, records, primaryKeys, foreignKeys, rows, unique } = params; + const ids: Record = {}; + rows.forEach((r) => { + const id = primaryKeys.map((k) => r[k.as]); + if (id.some((i) => i === undefined || i === null)) { + return; + } + const fk: (string | number)[] = []; + foreignKeys.forEach((i) => { + const v = r[i.as]; + fk.push(v); + }); + const stringifiedKeys = stringifyKeys(fk); + const rs = ids[stringifiedKeys] || []; + if (id.length !== 0) { + rs.push(id.length === 1 ? id[0] : id.join(':')); + ids[stringifiedKeys] = rs; + } + }); + + if (unique) { + records.forEach(({ record, stringifiedKeys }) => { + const [sub] = ids[stringifiedKeys] || []; + // eslint-disable-next-line no-param-reassign + record[key] = sub ?? null; + }); + } else { + records.forEach(({ record, stringifiedKeys }) => { + const sub = ids[stringifiedKeys]; + // eslint-disable-next-line no-param-reassign + record[key] = sub && sub.length !== 0 ? sub : null; + }); + } +}; + +const buildRecord = (params: { + fields: { key: string; field: DataFieldQ | RoleFieldQ }[]; + data: any; + subQueries?: { key: string; query: LinkFieldQ }[]; + primaryKeys: { column: string; as: string }[]; + metadata: boolean; + thing: string; + thingType: 'entity' | 'relation'; + queryPath: string; +}): { record: Record; subQueries: SingleRecordSubQuery[] } => { + const { fields, data, primaryKeys, thing, thingType, metadata, queryPath } = params; + const record: Record = {}; + const id = primaryKeys.map((pk) => data[pk.as]).filter((i) => i !== undefined); + const subQueries: SingleRecordSubQuery[] = []; + + if (metadata) { + record.$id = id.length === 0 ? null : id.length === 1 ? id[0] : id.join(':'); + record.$thing = thing; + record.$thingType = thingType; + record[QueryPath] = queryPath; + } + + fields.forEach(({ key, field }) => { + const res = buildFieldValue({ field, data, metadata }); + record[key] = res.value; + if (res.subQueries) { + subQueries.push(...res.subQueries); + } + }); + + if (params.subQueries) { + const sk = stringifyKeys(id); + params.subQueries.forEach(({ key, query }) => { + subQueries.push({ + key, + query, + record, + primaryKeys: id, + stringifiedKeys: sk, + }); + }); + } + + return { record, subQueries }; +}; + +const buildFieldValue = (params: { + field: DataFieldQ | RoleFieldQ; + data: any; + metadata: boolean; +}): { value: any; subQueries?: SingleRecordSubQuery[] } => { + const { field, data, metadata } = params; + switch (field.type) { + case 'data': { + return { value: data[field.as] }; + } + case 'role': { + const exists = field.primaryKeys.every((k) => data[k.as] !== null && data[k.as] !== undefined); + if (!exists) { + return { value: null }; + } + if (!('fields' in field)) { + const keys = field.primaryKeys.map((k) => data[k.as]); + const id = keys.length > 1 ? keys.join(':') : keys[0]; + return { value: field.unique ? id : [id] }; + } + const { record, subQueries } = buildRecord({ + fields: field.fields, + subQueries: field.subQueries, + primaryKeys: field.primaryKeys, + data, + metadata, + thing: field.thing, + thingType: field.thingType, + queryPath: field.queryPath, + }); + if (!field.subQueries || field.subQueries.length === 0) { + return { value: field.unique ? record : [record], subQueries }; + } + const primaryKeys = field.primaryKeys.map((i) => data[i.as]); + const s = field.subQueries.map(({ key, query }) => ({ + key, + query, + record, + primaryKeys, + stringifiedKeys: stringifyKeys(primaryKeys), + })); + return { + value: field.unique ? record : [record], + subQueries: [...s, ...subQueries], + }; + } + } +}; + +const sqlFields = (qs: (DataFieldQ | Omit)[]) => { + const selects = new Set(); + const joins: string[] = []; + const params: QueryParams = []; + qs.forEach((q) => { + if (q.type === 'data') { + selects.add(sqlData(q)); + } else if (q.type === 'role') { + const sql = sqlRole(q); + sql.selects.forEach((s) => selects.add(s)); + joins.push(...sql.joins); + params.push(...sql.params); + } + }); + return { selects: [...selects], joins, params }; +}; + +const sqlData = (q: DataFieldQ): string => { + return `${q.column} AS "${q.as}"`; +}; + +const sqlRole = ( + q: Omit | Omit, +): { selects: string[]; joins: string[]; params: QueryParams } => { + const selects = new Set(); + const joins = [q.join]; + const params: QueryParams = q.params ? [...q.params] : []; + + q.primaryKeys.forEach((i) => { + selects.add(`${i.column} AS "${i.as}"`); + }); + + if ('fields' in q) { + const sql = sqlFields(Object.values(q.fields).map((i) => i.field)); + sql.selects.forEach((s) => selects.add(s)); + joins.push(...sql.joins); + params.push(...sql.params); + } + + return { selects: [...selects], joins, params }; +}; + +const stringifyKeys = (primaryKeys: any[]) => primaryKeys.join('.'); + +const useOrdinalPlaceholder = (sql: string, params: QueryParams, start = 1) => { + let newSql = sql; + params.forEach((p, i) => { + newSql = newSql.replace(p.key, `${i + start}`); + }); + return newSql; +}; diff --git a/src/stateMachine/query/queryMachine.ts b/src/stateMachine/query/queryMachine.ts index b035e8e4..775bf914 100644 --- a/src/stateMachine/query/queryMachine.ts +++ b/src/stateMachine/query/queryMachine.ts @@ -9,6 +9,8 @@ import { runSurrealDbQueryMachine } from './surql/machine'; import { runTypeDbQueryMachine } from './tql/machine'; import type { SurrealPool } from '../../adapters/surrealDB/client'; import { VERSION } from '../../version'; +import type { Client } from 'pg'; +import { runPostgresSurrealDbQueryMachine } from './pg/machine'; type MachineContext = { bql: { @@ -69,7 +71,15 @@ type SurrealDBAdapter = { indices: number[]; }; -type Adapter = TypeDBAdapter | SurrealDBAdapter; +type PostgresDBAdapter = { + db: 'postgresDB'; + client: Client; + rawBql: RawBQLQuery[]; + bqlQueries: EnrichedBQLQuery[]; + indices: number[]; +}; + +type Adapter = TypeDBAdapter | SurrealDBAdapter | PostgresDBAdapter; export const queryMachine = createMachine( 'enrich', @@ -121,6 +131,20 @@ export const queryMachine = createMachine( indices: [], }; } + } else if (thing.db === 'postgresDB') { + if (!adapters[id]) { + const client = ctx.handles.postgresDB?.get(id)?.client; + if (!client) { + throw new Error(`PostgresDB client with id "${thing.defaultDBConnector.id}" does not exist`); + } + adapters[id] = { + db: 'postgresDB', + client, + rawBql: [], + bqlQueries: [], + indices: [], + }; + } } else { throw new Error(`Unsupported DB "${thing.db}"`); } @@ -140,6 +164,10 @@ export const queryMachine = createMachine( return runSurrealDbQueryMachine(a.bqlQueries, ctx.schema, ctx.config, a.client); } + if (a.db === 'postgresDB') { + return runPostgresSurrealDbQueryMachine(a.bqlQueries, ctx.schema, ctx.config, a.client); + } + throw new Error(`Unsupported DB "${JSON.stringify(a, null, 2)}"`); }); const results = await Promise.all(proms); diff --git a/src/types/config/base.ts b/src/types/config/base.ts index 1b8004e0..76655f9e 100644 --- a/src/types/config/base.ts +++ b/src/types/config/base.ts @@ -4,6 +4,7 @@ import type { TypeDBHandles, } from './typedb'; import type { SurrealDBProviderObject as SurrealDBProvider, SurrealDBHandles } from './surrealdb'; +import type { PostgresDBHandles, PostgresDBProvider } from './postgres'; export type QueryConfig = { noMetadata?: boolean; @@ -30,7 +31,7 @@ export type BormConfig = { dbConnectors: [Provider, ...Provider[]]; // minimum one }; -export type Provider = TypeDBProvider | TypeDBClusterProvider | SurrealDBProvider; +export type Provider = TypeDBProvider | TypeDBClusterProvider | SurrealDBProvider | PostgresDBProvider; export interface CommonProvider { id: string; @@ -47,6 +48,7 @@ export type DBConnector = { export type AllDbHandles = { typeDB: TypeDBHandles; surrealDB: SurrealDBHandles; + postgresDB: PostgresDBHandles; }; type AtLeastOne }> = Partial & U[keyof U]; diff --git a/src/types/config/postgres.ts b/src/types/config/postgres.ts new file mode 100644 index 00000000..348234cc --- /dev/null +++ b/src/types/config/postgres.ts @@ -0,0 +1,12 @@ +import type { Client } from 'pg'; +import type { CommonProvider } from './base'; + +export interface PostgresDBProvider extends CommonProvider { + provider: 'postgresDB'; + host: string; + port: number; + user: string; + password: string; +} + +export type PostgresDBHandles = Map; diff --git a/src/types/schema/fields.ts b/src/types/schema/fields.ts index 400d61e3..be6a907e 100644 --- a/src/types/schema/fields.ts +++ b/src/types/schema/fields.ts @@ -16,8 +16,17 @@ export type RoleField = { // MAYBE: rigths => Why not, maybe relation.particular child has better rigths than otherchild.relation.particular child // NO: ordered => This can be really messy. Probably roles should never be ordered as relations are precisely the ones having the index dbConnector?: DBConnector; + dbConfig?: PgRoleConfig; }; +export interface PgRoleConfig { + db: 'postgres'; + fields: { path: string; type: PgDataType }[]; +} + +// TODO: Add all +export type PgDataType = 'INT' | 'TEXT' | 'UUID' | 'DATE' | 'TIME' | 'TIMESTAMP'; + export type LinkField = BormField & { relation: string; cardinality: DiscreteCardinality; @@ -112,6 +121,7 @@ export type DataField = BormField & { shared?: boolean; validations?: Validations; isVirtual?: boolean; + dbPath?: string; dbValue?: { surrealDB?: string; typeDB?: string }; //enhance this types and pack it with isVirtual or default as they work together dbConnectors?: [DBConnector, ...DBConnector[]]; } & AllDataField; diff --git a/tests/adapters/postgresDB/mocks/config.ts b/tests/adapters/postgresDB/mocks/config.ts new file mode 100644 index 00000000..ab2f674e --- /dev/null +++ b/tests/adapters/postgresDB/mocks/config.ts @@ -0,0 +1,18 @@ +import type { BormConfig } from '../../../../src'; + +export const postgresDBTestConfig: BormConfig = { + server: { + provider: 'blitz-orm-js', + }, + dbConnectors: [ + { + id: 'default', + provider: 'postgresDB', + dbName: 'test', + user: 'test', + password: 'test', + host: 'localhost', + port: 5432, + }, + ], +}; diff --git a/tests/adapters/postgresDB/mocks/data.sql b/tests/adapters/postgresDB/mocks/data.sql new file mode 100644 index 00000000..e464d8d4 --- /dev/null +++ b/tests/adapters/postgresDB/mocks/data.sql @@ -0,0 +1,151 @@ +INSERT INTO "User" ("id", "name", "email") +VALUES + ('user1', 'Antoine', 'antoine@test.com'), + ('user2', 'Loic', 'loic@test.com'), + ('user3', 'Ann', 'ann@test.com'), + ('user4', 'Ben', NULL), + ('user5', 'Charlize', 'charlize@test.com'); + +INSERT INTO "Thing" ("id", "stuff") +VALUES + ('thing1', 'A'), + ('thing2', 'B'), + ('thing3', 'C'), + ('thing4', 'D'), + ('thing5', 'E'); + +INSERT INTO "SubthingOne" ("id", "stuff") +VALUES + ('subthingone1', 'F'), + ('subthingone2', 'G'), + ('subthingone3', 'H'); + +INSERT INTO "SubthingTwo" ("id", "stuff") +VALUES + ('subthingtwo1', 'I'), + ('subthingtwo2', 'J'), + ('subthingtwo3', 'K'); + +INSERT INTO "ThingRelation" ("id", "thingId", "rootId", "extraId") +VALUES + ('tr2', 'thing5', 'thing2', 'thing1'), + ('tr3', 'thing5', 'thing1', 'thing1'), -- Can not have multiple thingId (thingId: ['thing5', 'thing5']) + ('tr4', 'thing5', 'thing1', 'thing1'), + ('tr5', 'thing5', 'thing1', 'thing1'), + ('tr6', 'thing5', 'thing2', 'thing1'), + ('tr7', 'thing5', 'thing3', 'thing1'), + ('tr8', 'thing5', 'thing4', 'thing1'), + ('tr9', 'thing5', 'thing4', 'thing1'), + ('tr10', NULL, NULL, 'thing1'), + ('tr11', NULL, 'thing4', 'thing5'); + +INSERT INTO "SuperUser" ("id", "name", "email", "power") +VALUES ('superuser1', 'Beatrix Kiddo', 'black.mamba@deadly-viper.com', 'katana'); + +INSERT INTO "God" ("id", "name", "email", "power", "isEvil") +VALUES ('god1', 'Richard David James', 'afx@rephlex.com', 'mind control', TRUE); + +INSERT INTO "Account" ("id", "provider", "profile") +VALUES + ('account1-1', 'google', '{"hobby":["Running"]}'), + ('account1-2', 'facebook', NULL), + ('account1-3', 'github', NUll), + ('account2-1', 'google', NULL), + ('account3-1', 'facebook', NULL); + +INSERT INTO "User-Accounts" ("id", "userId", "accountId") +VALUES + ('ua1-1', 'user1', 'account1-1'), + ('ua1-2', 'user1', 'account1-2'), + ('ua1-3', 'user1', 'account1-3'), + ('ua2-1', 'user2', 'account2-1'), + ('ua3-1', 'user3', 'account3-1'); + +INSERT INTO "Space" ("id", "name") +VALUES + ('space-1', 'Production'), + ('space-2', 'Dev'), + ('space-3', 'Not-owned'); + +INSERT INTO "Power" ("id", "description") +VALUES ('power1', 'useless power'); + +INSERT INTO "Space-User" ("id", "spaceId", "userId", "powerId") +VALUES + ('u1-s1', 'space-1', 'user1', NULL), + ('u1-s2', 'space-2', 'user1', NULL), + ('u5-s1', 'space-1', 'user5', NULL), + ('u2-s2', 'space-2', 'user2', NULL), + ('u3-s2', 'space-2', 'user3', 'power1'); + +INSERT INTO "UserTag" ("id", "userId") +VALUES + ('tag-1', 'user1'), + ('tag-2', 'user1'), -- Can not have multiple thingId (userId: ['user1', 'user3']) + ('tag-3', 'user2'), + ('tag-4', 'user2'); + +-- NOTE: Postgres does not support flex type +-- $yellowFreeForAll "yellowFreeForAll" isa Color·freeForAll, has longAttribute 7; +-- $blueFreeForAll "blueFreeForAll" isa Color·freeForAll, has stringAttribute "hey"; +-- $redFreeForAll "redFreeForAll" isa Color·freeForAll, has stringAttribute "yay"; + +-- Can not set Color.freeForAll +INSERT INTO "Color" ("id") +VALUES + ('red'), + ('yellow'), + ('blue'); + +INSERT INTO "UserTagGroup" ("id", "tagId", "colorId", "spaceId") +VALUES + ('utg-1', 'tag-1', 'yellow', NULL), -- Can not have multiple tagId (tagId: ['tag-1', 'tag-2']) + ('utg-2', 'tag-3', 'blue', 'space-3'); + +INSERT INTO "Kind" ("id", "name", "spaceId") +VALUES + ('kind-book', 'book', 'space-2'); + +INSERT INTO "Self" ("id", "spaceId", "ownerId") +VALUES +('self1', 'space-2', NULL), +('self2', 'space-2', 'self1'), +('self3', 'space-2', 'self2'), +('self4', 'space-2', 'self2'); + +INSERT INTO "Hook" ("id", "requiredOption") +VALUES +('hook1', 'a'), +('hook2', 'b'), +('hook3', 'c'), +('hook4', 'a'), +('hook5', 'b'); + +-- Hotel +------------------------------------------------------------------------------- + +INSERT INTO "Hotel" ("id", "name", "location") VALUES +('h1', 'Grand Palace Hotel', 'New York, USA'), +('h2', 'Ocean View Resort', 'Miami, USA'), +('h3', 'Mountain Lodge', 'Denver, USA'); + +INSERT INTO "Room" ("id", "hotelId", "pricePerNight", "isAvailable") VALUES +('r1', 'h1', 150.00, TRUE), +('r2', 'h1', 200.00, FALSE), +('r3', 'h2', 180.00, TRUE), +('r4', 'h2', 220.00, TRUE), +('r5', 'h3', 100.00, FALSE); + +INSERT INTO "Guest" ("id", "name", "email", "phone") VALUES +('g1', 'John Doe', 'john.doe@example.com', '123-456-7890'), +('g2', 'Jane Smith', 'jane.smith@example.com', '987-654-3210'), +('g3', 'Robert Brown', 'robert.brown@example.com', NULL); + +INSERT INTO "Booking" ("id", "roomId", "guestId", "checkIn", "checkOut", "status", "totalCost") VALUES +('b1', 'r2', 'g1', '2024-02-01', '2024-02-05', 'checked-in', 800.00), +('b2', 'r5', 'g2', '2024-03-10', '2024-03-15', 'reserved', 500.00), +('b3', 'r3', 'g3', '2024-01-20', '2024-01-25', 'checked-out', 900.00); + +INSERT INTO "Payment" ("id", "bookingId", "amountPaid", "paymentDate") VALUES +('p1', 'b1', 800.00, '2024-02-01 14:30:00'), +('p2', 'b3', 900.00, '2024-01-20 10:00:00'); diff --git a/tests/adapters/postgresDB/mocks/schema.sql b/tests/adapters/postgresDB/mocks/schema.sql new file mode 100644 index 00000000..ba9cbfee --- /dev/null +++ b/tests/adapters/postgresDB/mocks/schema.sql @@ -0,0 +1,248 @@ +CREATE TABLE IF NOT EXISTS "User" ( + "id" TEXT PRIMARY KEY, + "name" TEXT, + "email" TEXT UNIQUE +); + +CREATE TABLE IF NOT EXISTS "SuperUser" ( + "id" TEXT PRIMARY KEY, + "name" TEXT, + "email" TEXT UNIQUE, + "power" TEXT +); + +CREATE TABLE IF NOT EXISTS "God" ( + "id" TEXT PRIMARY KEY, + "name" TEXT, + "email" TEXT UNIQUE, + "power" TEXT, + "isEvil" BOOLEAN +); + +CREATE TABLE IF NOT EXISTS "Space" ( + "id" TEXT PRIMARY KEY, + "name" TEXT +); + +CREATE TABLE IF NOT EXISTS "Account" ( + "id" TEXT PRIMARY KEY, + "provider" TEXT, + "profile" JSONB +); + +CREATE TABLE IF NOT EXISTS "Session" ( + "id" TEXT PRIMARY KEY, + "expires" TIMESTAMPTZ, + "sessionToken" TEXT UNIQUE +); + +CREATE TABLE IF NOT EXISTS "VerificationToken" ( + "id" TEXT PRIMARY KEY, + "expires" TIMESTAMPTZ, + "identifier" TEXT, + "token" TEXT UNIQUE +); + +CREATE TABLE IF NOT EXISTS "Thing" ( + "id" TEXT PRIMARY KEY, + "stuff" TEXT +); + +CREATE TABLE IF NOT EXISTS "SubthingOne" ( + "id" TEXT PRIMARY KEY, + "stuff" TEXT +); + +CREATE TABLE IF NOT EXISTS "SubthingTwo" ( + "id" TEXT PRIMARY KEY, + "stuff" TEXT +); + +CREATE TABLE IF NOT EXISTS "CascadeThing" ( + "id" TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS "Color" ( + "id" TEXT PRIMARY KEY, + -- freeForAll, + "value" TEXT +); + +CREATE TABLE IF NOT EXISTS "Power" ( + "id" TEXT PRIMARY KEY, + "description" TEXT +); + +CREATE TABLE IF NOT EXISTS "Hook" ( + "id" TEXT PRIMARY KEY, + "requiredOption" TEXT, + "manyOptions" TEXT, -- Cardinality: Not supported + "fnValidatedField" TEXT, + "timestamp" TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS "Company" ( + "id" TEXT PRIMARY KEY, + "name" TEXT NOT NULL, + "industry" TEXT +); + +CREATE TABLE IF NOT EXISTS "User-Accounts" ( + "id" TEXT PRIMARY KEY, + "accountId" TEXT REFERENCES "Account"("id"), + "userId" TEXT REFERENCES "User"("id") +); + +CREATE TABLE IF NOT EXISTS "User-Sessions" ( + "id" TEXT PRIMARY KEY, + "sessionId" TEXT REFERENCES "Session"("id"), + "userId" TEXT REFERENCES "User"("id") +); + +CREATE TABLE IF NOT EXISTS "Space-User" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id"), + "userId" TEXT REFERENCES "User"("id"), + "powerId" TEXT REFERENCES "Power"("id") +); + +CREATE TABLE IF NOT EXISTS "UserTag" ( + "id" TEXT PRIMARY KEY, + "name" TEXT, + "userId" TEXT REFERENCES "User"("id") +); + +CREATE TABLE IF NOT EXISTS "UserTagGroup" ( + "id" TEXT PRIMARY KEY, + "tagId" TEXT REFERENCES "UserTag"("id"), + "colorId" TEXT REFERENCES "Color"("id"), + "spaceId" TEXT REFERENCES "Space"("id") +); + +CREATE TABLE IF NOT EXISTS "SpaceObj" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id") +); + +CREATE TABLE IF NOT EXISTS "SpaceDef" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id"), + "description" TEXT +); + +CREATE TABLE IF NOT EXISTS "Kind" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id"), + "description" TEXT, + "name" TEXT +); + +CREATE TABLE IF NOT EXISTS "Field" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id"), + "description" TEXT, + "name" TEXT, + "cardinality" TEXT, + "kindId" TEXT REFERENCES "Kind"("id") +); + +CREATE TABLE IF NOT EXISTS "DataField" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id"), + "description" TEXT, + "name" TEXT, + "cardinality" TEXT, + "kindId" TEXT REFERENCES "Kind"("id"), + "type" TEXT, + "computeType" TEXT +); + +CREATE TABLE IF NOT EXISTS "DataValue" ( + "id" TEXT PRIMARY KEY, + "type" TEXT, + "dataFieldId" TEXT REFERENCES "DataField"("id") +); + +CREATE TABLE IF NOT EXISTS "Expression" ( + "id" TEXT PRIMARY KEY, + "value" TEXT, + "type" TEXT, + "dataFieldId" TEXT REFERENCES "DataField"("id") +); + +CREATE TABLE IF NOT EXISTS "Self" ( + "id" TEXT PRIMARY KEY, + "spaceId" TEXT REFERENCES "Space"("id"), + "ownerId" TEXT REFERENCES "Self"("id") +); + +CREATE TABLE IF NOT EXISTS "ThingRelation" ( + "id" TEXT PRIMARY KEY, + "moreStuff" TEXT, + "thingId" TEXT REFERENCES "Thing"("id"), + "rootId" TEXT REFERENCES "Thing"("id"), + "extraId" TEXT REFERENCES "Thing"("id") +); + +CREATE TABLE IF NOT EXISTS "CascadeRelation" ( + "id" TEXT PRIMARY KEY, + "thingId" TEXT REFERENCES "CascadeThing"("id") +); + +CREATE TABLE IF NOT EXISTS "HookParent" ( + "id" TEXT PRIMARY KEY, + "hookId" TEXT REFERENCES "Hook"("id"), + "mainHookId" TEXT REFERENCES "Hook"("id") +); + +CREATE TABLE IF NOT EXISTS "HookATag" ( + "id" TEXT PRIMARY KEY, + "hookTypeAId" TEXT REFERENCES "Hook"("id"), + "otherHookId" TEXT REFERENCES "Hook"("id") +); + +CREATE TABLE IF NOT EXISTS "Employee" ( + "id" TEXT PRIMARY KEY, + "name" TEXT NOT NULL, + "companyId" TEXT REFERENCES "Company"("id") +); + +-- Hotel +------------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS "Hotel" ( + "id" TEXT PRIMARY KEY, + "name" TEXT NOT NULL, + "location" VARCHAR(255) +); + +CREATE TABLE IF NOT EXISTS "Room" ( + "id" TEXT PRIMARY KEY, + "hotelId" TEXT REFERENCES "Hotel"("id"), + "pricePerNight" DECIMAL(10,2) NOT NULL, + "isAvailable" BOOLEAN DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS "Guest" ( + "id" TEXT PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "email" VARCHAR(255) UNIQUE, + "phone" VARCHAR(20) +); + +CREATE TABLE IF NOT EXISTS "Booking" ( + "id" TEXT PRIMARY KEY, + "roomId" TEXT REFERENCES "Room"("id"), + "guestId" TEXT REFERENCES "Guest"("id"), + "checkIn" DATE NOT NULL, + "checkOut" DATE NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'reserved', -- 'reserved', 'checked-in', 'checked-out', 'canceled' + "totalCost" DECIMAL(10,2) NOT NULL +); + +CREATE TABLE IF NOT EXISTS "Payment" ( + "id" TEXT PRIMARY KEY, + "bookingId" TEXT REFERENCES "Booking"("id"), + "amountPaid" DECIMAL(10,2) NOT NULL, + "paymentDate" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/tests/helpers/init.ts b/tests/helpers/init.ts index 2934130a..5f0858be 100644 --- a/tests/helpers/init.ts +++ b/tests/helpers/init.ts @@ -2,6 +2,7 @@ import { setup } from './setup'; import { schema } from '../mocks/schema'; import { surrealDBTestConfig } from '../adapters/surrealDB/mocks/config'; import { typeDBTestConfig } from '../adapters/typeDB/mocks/config'; +import { postgresDBTestConfig } from '../adapters/postgresDB/mocks/config'; // eslint-disable-next-line turbo/no-undeclared-env-vars const adapter = process.env.BORM_TEST_ADAPTER; @@ -10,12 +11,12 @@ if (!adapter) { throw new Error('BORM_TEST_ADAPTER is not defined'); } -if (['surrealDB', 'typeDB'].includes(adapter) === false) { +if (['postgresDB', 'surrealDB', 'typeDB'].includes(adapter) === false) { throw new Error(`Unsupported adapter "${adapter}"`); } -//console.log('adapter', adapter); -const config = adapter === 'surrealDB' ? surrealDBTestConfig : typeDBTestConfig; +const config = + adapter === 'postgresDB' ? postgresDBTestConfig : adapter === 'surrealDB' ? surrealDBTestConfig : typeDBTestConfig; export const init = async () => setup({ diff --git a/tests/mocks/schema.ts b/tests/mocks/schema.ts index 4e278f6b..b192d90c 100644 --- a/tests/mocks/schema.ts +++ b/tests/mocks/schema.ts @@ -687,8 +687,9 @@ export const schema: BormSchema = { roles: { accounts: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'accountId', type: 'TEXT' }] }, }, - user: { cardinality: 'ONE' }, + user: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'userId', type: 'TEXT' }] } }, }, }, 'User-Sessions': { @@ -698,8 +699,9 @@ export const schema: BormSchema = { roles: { sessions: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'sessionId', type: 'TEXT' }] }, }, - user: { cardinality: 'ONE' }, + user: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'userId', type: 'TEXT' }] } }, }, }, 'Space-User': { @@ -707,9 +709,9 @@ export const schema: BormSchema = { defaultDBConnector: { id: 'default', path: 'Space-User' }, dataFields: [{ ...id }], roles: { - spaces: { cardinality: 'MANY' }, - users: { cardinality: 'MANY' }, - power: { cardinality: 'ONE' }, + spaces: { cardinality: 'MANY', dbConfig: { db: 'postgres', fields: [{ path: 'spaceId', type: 'TEXT' }] } }, + users: { cardinality: 'MANY', dbConfig: { db: 'postgres', fields: [{ path: 'userId', type: 'TEXT' }] } }, + power: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'powerId', type: 'TEXT' }] } }, }, }, 'UserTag': { @@ -719,6 +721,7 @@ export const schema: BormSchema = { roles: { users: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'userId', type: 'TEXT' }] }, }, }, linkFields: [ @@ -745,9 +748,10 @@ export const schema: BormSchema = { roles: { tags: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'tagId', type: 'TEXT' }] }, }, - color: { cardinality: 'ONE' }, - space: { cardinality: 'ONE' }, + color: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'colorId', type: 'TEXT' }] } }, + space: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'spaceId', type: 'TEXT' }] } }, }, }, 'SpaceObj': { @@ -755,7 +759,7 @@ export const schema: BormSchema = { defaultDBConnector: { id: 'default', path: 'SpaceObj' }, // in the future multiple can be specified in the config file. Either they fetch full schemas or they will require a relation to merge attributes from different databases dataFields: [id], roles: { - space: { cardinality: 'ONE' }, + space: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'spaceId', type: 'TEXT' }] } }, }, hooks: { pre: [ @@ -858,6 +862,7 @@ export const schema: BormSchema = { roles: { kinds: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'kindId', type: 'TEXT' }] }, }, }, defaultDBConnector: { id: 'default', as: 'SpaceDef', path: 'Field' }, // in the future multiple can be specified in the config file. Either they fetch full schemas or they will require a relation to merge attributes from different databases @@ -966,7 +971,10 @@ export const schema: BormSchema = { ], }, roles: { - dataField: { cardinality: 'ONE' }, + dataField: { + cardinality: 'ONE', + dbConfig: { db: 'postgres', fields: [{ path: 'dataFieldId', type: 'TEXT' }] }, + }, }, defaultDBConnector: { id: 'default', path: 'DataValue' }, // in the future multiple can be specified in the config file. Either they fetch full schemas or they will require a relation to merge attributes from different databases }, @@ -1006,7 +1014,10 @@ export const schema: BormSchema = { ], }, roles: { - dataField: { cardinality: 'ONE' }, + dataField: { + cardinality: 'ONE', + dbConfig: { db: 'postgres', fields: [{ path: 'dataFieldId', type: 'TEXT' }] }, + }, }, }, 'Self': { @@ -1014,7 +1025,7 @@ export const schema: BormSchema = { extends: 'SpaceObj', defaultDBConnector: { id: 'default', as: 'SpaceObj', path: 'Self' }, roles: { - owner: { cardinality: 'ONE' }, + owner: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'ownerId', type: 'TEXT' }] } }, }, linkFields: [ { @@ -1041,6 +1052,7 @@ export const schema: BormSchema = { roles: { things: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'thingId', type: 'TEXT' }] }, }, root: { cardinality: 'ONE' }, extra: { cardinality: 'ONE' }, @@ -1054,6 +1066,7 @@ export const schema: BormSchema = { roles: { things: { cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'thingId', type: 'TEXT' }] }, }, }, hooks: { @@ -1085,8 +1098,8 @@ export const schema: BormSchema = { defaultDBConnector: { id: 'default', path: 'HookParent' }, dataFields: [{ ...id }], roles: { - hooks: { cardinality: 'MANY' }, - mainHook: { cardinality: 'ONE' }, + hooks: { cardinality: 'MANY', dbConfig: { db: 'postgres', fields: [{ path: 'hookId', type: 'TEXT' }] } }, + mainHook: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'mainHookId', type: 'TEXT' }] } }, }, }, 'HookATag': { @@ -1094,8 +1107,14 @@ export const schema: BormSchema = { defaultDBConnector: { id: 'default', path: 'HookATag' }, dataFields: [{ ...id }], roles: { - hookTypeA: { cardinality: 'ONE' }, - otherHooks: { cardinality: 'MANY' }, + hookTypeA: { + cardinality: 'ONE', + dbConfig: { db: 'postgres', fields: [{ path: 'hookTypeAId', type: 'TEXT' }] }, + }, + otherHooks: { + cardinality: 'MANY', + dbConfig: { db: 'postgres', fields: [{ path: 'otherHookId', type: 'TEXT' }] }, + }, }, hooks: { pre: [ @@ -1121,7 +1140,157 @@ export const schema: BormSchema = { defaultDBConnector: { id: 'default', path: 'Employee' }, dataFields: [{ ...id }, { ...name, validations: { required: true } }], roles: { - company: { cardinality: 'ONE' }, + company: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'companyId', type: 'TEXT' }] } }, + }, + }, + 'Hotel': { + idFields: ['id'], + defaultDBConnector: { id: 'default', path: 'Hotel' }, + dataFields: [ + { ...id }, + { ...name, validations: { required: true } }, + { + path: 'location', + contentType: 'TEXT', + }, + ], + linkFields: [ + { + path: 'rooms', + cardinality: 'MANY', + relation: 'Room', + plays: 'hotel', + target: 'relation', + }, + ], + }, + 'Room': { + idFields: ['id'], + defaultDBConnector: { id: 'default', path: 'Room' }, + dataFields: [ + { ...id }, + { + path: 'pricePerNight', + contentType: 'NUMBER_DECIMAL', + validations: { required: true }, + }, + { + path: 'isAvailable', + contentType: 'BOOLEAN', + validations: { required: true }, + }, + ], + linkFields: [ + { + path: 'bookings', + cardinality: 'MANY', + relation: 'Booking', + plays: 'room', + target: 'relation', + }, + { + path: 'guests', + cardinality: 'MANY', + relation: 'Booking', + plays: 'room', + target: 'role', + }, + ], + roles: { + hotel: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'hotelId', type: 'TEXT' }] } }, + }, + }, + 'Guest': { + idFields: ['id'], + defaultDBConnector: { id: 'default', path: 'Guest' }, + dataFields: [ + { ...id }, + { ...name, validations: { required: true } }, + { + path: 'email', + contentType: 'TEXT', + validations: { required: true }, + }, + { + path: 'phone', + contentType: 'TEXT', + }, + ], + linkFields: [ + { + path: 'bookings', + cardinality: 'MANY', + relation: 'Booking', + plays: 'guest', + target: 'relation', + }, + { + path: 'rooms', + cardinality: 'MANY', + relation: 'Booking', + plays: 'guest', + target: 'role', + }, + ], + }, + 'Booking': { + idFields: ['id'], + defaultDBConnector: { id: 'default', path: 'Booking' }, + dataFields: [ + { ...id }, + { + path: 'checkIn', + contentType: 'DATE', + validations: { required: true }, + }, + { + path: 'checkOut', + contentType: 'DATE', + validations: { required: true }, + }, + { + path: 'status', + contentType: 'TEXT', + validations: { required: true, enum: ['reserved', 'checked-in', 'checked-out', 'canceled'] }, + }, + { + path: 'totalCost', + contentType: 'NUMBER_DECIMAL', + validations: { required: true }, + }, + ], + linkFields: [ + { + path: 'payments', + cardinality: 'MANY', + relation: 'Payment', + plays: 'booking', + target: 'relation', + }, + ], + roles: { + room: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'roomId', type: 'TEXT' }] } }, + guest: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'guestId', type: 'TEXT' }] } }, + }, + }, + 'Payment': { + idFields: ['id'], + defaultDBConnector: { id: 'default', path: 'Payment' }, + dataFields: [ + { ...id }, + { + path: 'amountPaid', + contentType: 'NUMBER_DECIMAL', + validations: { required: true }, + }, + { + path: 'paymentDate', + contentType: 'DATE', + validations: { required: true }, + }, + ], + roles: { + booking: { cardinality: 'ONE', dbConfig: { db: 'postgres', fields: [{ path: 'bookingId', type: 'TEXT' }] } }, }, }, }, diff --git a/tests/multidb/mocks/schema.tql b/tests/multidb/mocks/schema.tql index 44a75b63..3eb92933 100644 --- a/tests/multidb/mocks/schema.tql +++ b/tests/multidb/mocks/schema.tql @@ -15,7 +15,7 @@ User sub entity, plays SpaceMember:member, plays ProjectExecutor:executor; -User·name sub attribute, +name sub attribute, value string; User·email sub attribute, @@ -23,7 +23,7 @@ User·email sub attribute, Space sub entity, owns id @key, - owns Space·name, + owns name, plays SpaceOwner:space, plays SpaceMember:space, plays SpaceProject:space; diff --git a/tests/postgres.sh b/tests/postgres.sh new file mode 100755 index 00000000..853bfb9b --- /dev/null +++ b/tests/postgres.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +CONTAINER_NAME=borm_test_postgres +USER=test +PASSWORD=test +DB=test + +# Function to clean up the container +cleanup() { + echo "Stopping and removing container..." + docker stop ${CONTAINER_NAME} >/dev/null 2>&1 + docker rm ${CONTAINER_NAME} >/dev/null 2>&1 + exit $((TEST_FAILED ? 1 : 0)) +} + +# Set up trap to call cleanup function on script exit +trap cleanup EXIT INT TERM + +# Function to parse command line argumentsppa +parse_args() { + VITEST_ARGS=() + for arg in "$@" + do + case $arg in + -link=*) + # We'll ignore this parameter now + ;; + *) + VITEST_ARGS+=("$arg") + ;; + esac + done +} + +# Parse the command line arguments +parse_args "$@" + +# Run the DB +docker run \ + --name ${CONTAINER_NAME} \ + -e POSTGRES_USER=${USER} \ + -e POSTGRES_PASSWORD=${PASSWORD} \ + -e POSTGRES_DB=${DB} \ + -p 5432:5432 \ + --rm \ + -d \ + postgres + +sleep 1 + +# Create the tables +docker run \ + -it \ + --rm \ + --name borm_test_schema \ + --network container:${CONTAINER_NAME} \ + -v $(pwd)/tests/adapters/postgresDB/mocks:/tests \ + postgres \ + psql -h localhost -p 5432 -U ${USER} -d ${DB} -f /tests/schema.sql + +sleep 1 + +# Insert data +docker run \ + -it \ + --rm \ + --name borm_test_data \ + --network container:${CONTAINER_NAME} \ + -v $(pwd)/tests/adapters/postgresDB/mocks:/tests \ + postgres \ + psql -h localhost -p 5432 -U ${USER} -d ${DB} -f /tests/data.sql + +sleep 1 + +# Run tests and capture output +if CONTAINER_NAME=${CONTAINER_NAME} npx vitest run "${VITEST_ARGS[@]}"; then + echo "Tests passed. Container ${CONTAINER_NAME} is still running." + TEST_FAILED=false +else + echo "Tests failed. Container ${CONTAINER_NAME} is still running." + TEST_FAILED=true +fi + +echo "Press Ctrl+C to stop the script and remove the container." + +# # Keep the script running, which keeps the container alive +# while true; do +# sleep 1 +# done diff --git a/tests/test.sh b/tests/surreal.sh similarity index 100% rename from tests/test.sh rename to tests/surreal.sh diff --git a/tests/unit/queries/query.ts b/tests/unit/queries/query.ts index f0584f44..746286ec 100644 --- a/tests/unit/queries/query.ts +++ b/tests/unit/queries/query.ts @@ -23,7 +23,8 @@ export const testQuery = createTest('Query', (ctx) => { await expect(res).toBeNull(); }); - it('e1[entity] - basic and direct link to relation', async () => { + it('TODO{P}:e1[entity] - basic and direct link to relation', async () => { + // Postgres: Inherited entity (God and SuperUser) is not supported const query = { $entity: 'User' }; const expectedRes = [ { @@ -103,6 +104,19 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(res, 'id')).toEqual(expectedRes); }); + it('e1.alt[entity] - basic and direct link to relation', async () => { + const query = { $entity: 'Power' }; + const expected = [ + { + 'id': 'power1', + 'description': 'useless power', + 'space-user': 'u3-s2', + }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(res).toEqual(expected); + }); + it('e1.b[entity] - basic and direct link to relation sub entity', async () => { const query = { $entity: 'God' }; const expectedRes = [ @@ -151,7 +165,8 @@ export const testQuery = createTest('Query', (ctx) => { // expect(res['user-tags']).toHaveLength(expectedRes['user-tags'].length); }); - it('e3[entity, nested] - direct link to relation, query nested ', async () => { + it('TODO{P}:e3[entity, nested] - direct link to relation, query nested ', async () => { + // Postgres: Inherited entity (God and SuperUser) is not supported const query = { $entity: 'User', $fields: ['id', { $path: 'user-tags' }] }; const expectedRes = [ { @@ -256,6 +271,15 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); + it('e3.alt[entity, nested] - direct link to relation, query nested ', async () => { + const query = { $entity: 'Power', $fields: ['id', { $path: 'space-user' }] }; + const expected = [ + { 'id': 'power1', 'space-user': { id: 'u3-s2', spaces: ['space-2'], users: ['user3'], power: 'power1' } }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(res).toEqual(expected); + }); + it('opt1[options, noMetadata', async () => { const query = { $entity: 'User', $id: 'user1' }; const expectedRes = { @@ -280,7 +304,7 @@ export const testQuery = createTest('Query', (ctx) => { expect(res['user-tags']).toHaveLength(expectedRes['user-tags'].length); }); - it('TODO{TS}:opt2[options, debugger', async () => { + it('TODO{PTS}:opt2[options, debugger', async () => { const query = { $entity: 'User', $id: 'user1' }; const expectedRes = { '$id': 'user1', @@ -342,17 +366,15 @@ export const testQuery = createTest('Query', (ctx) => { const query = { $entity: 'User', $id: 'user4', - $fields: ['spaces', 'email', 'user-tags'], + $fields: ['id', 'spaces', 'email', 'user-tags'], }; const expectedRes = { - '$thing': 'User', - '$thingType': 'entity', + 'id': 'user4', 'email': null, //Example field - '$id': 'user4', 'spaces': null, //example linkfield from intermediary relation 'user-tags': null, //example linkfield from direct relation }; - const res = await ctx.query(query, { returnNulls: true }); + const res = await ctx.query(query, { returnNulls: true, noMetadata: true }); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res, 'id')).toEqual(expectedRes); @@ -362,16 +384,14 @@ export const testQuery = createTest('Query', (ctx) => { const query = { $entity: 'User', $id: 'user4', - $fields: ['spaces', 'email'], + $fields: ['id', 'spaces', 'email'], }; const expectedRes = { - $thing: 'User', - $thingType: 'entity', + id: 'user4', email: null, //Example field - $id: 'user4', spaces: null, //example linkfield from intermediary relation }; - const res = await ctx.query(query, { returnNulls: true }); + const res = await ctx.query(query, { noMetadata: true, returnNulls: true }); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res, 'id')).toEqual(expectedRes); @@ -441,36 +461,41 @@ export const testQuery = createTest('Query', (ctx) => { }); it('r2[relation] - filtered fields', async () => { - const query = { $relation: 'User-Accounts', $fields: ['user'] }; + const query = { $relation: 'User-Accounts', $fields: ['id', 'user'] }; const expectedRes = [ { $thing: 'User-Accounts', $thingType: 'relation', $id: 'ua1-1', + id: 'ua1-1', user: 'user1', }, { $thing: 'User-Accounts', $thingType: 'relation', $id: 'ua1-2', + id: 'ua1-2', user: 'user1', }, { $thing: 'User-Accounts', $thingType: 'relation', $id: 'ua1-3', + id: 'ua1-3', user: 'user1', }, { $thing: 'User-Accounts', $thingType: 'relation', $id: 'ua2-1', + id: 'ua2-1', user: 'user2', }, { $thing: 'User-Accounts', $thingType: 'relation', $id: 'ua3-1', + id: 'ua3-1', user: 'user3', }, ]; @@ -489,71 +514,46 @@ export const testQuery = createTest('Query', (ctx) => { it('r3[relation, nested] - nested entity', async () => { const query = { $relation: 'User-Accounts', - $fields: ['id', { $path: 'user', $fields: ['name'] }], + $fields: ['id', { $path: 'user', $fields: ['id', 'name'] }], }; const expectedRes = [ { - $thing: 'User-Accounts', - $thingType: 'relation', - $id: 'ua1-1', id: 'ua1-1', user: { - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', }, }, { - $thing: 'User-Accounts', - $thingType: 'relation', - $id: 'ua1-2', id: 'ua1-2', user: { - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', }, }, { - $thing: 'User-Accounts', - $thingType: 'relation', - $id: 'ua1-3', id: 'ua1-3', user: { - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', }, }, { - $thing: 'User-Accounts', - $thingType: 'relation', - $id: 'ua2-1', id: 'ua2-1', user: { - $thing: 'User', - $thingType: 'entity', - $id: 'user2', + id: 'user2', name: 'Loic', }, }, { - $thing: 'User-Accounts', - $thingType: 'relation', - $id: 'ua3-1', id: 'ua3-1', user: { - $thing: 'User', - $thingType: 'entity', - $id: 'user3', + id: 'user3', name: 'Ann', }, }, ]; - const res = await ctx.query(query); + const res = await ctx.query(query, { noMetadata: true }); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res, '$id')).toEqual(expectedRes); @@ -564,7 +564,9 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('r4[relation, nested, direct] - nested relation direct on relation', async () => { + it('TODO{P}:r4[relation, nested, direct] - nested relation direct on relation', async () => { + // Postgres: + // - Role field UserTagGroup.tagId and UserTag.tagId cannot reference multiple records. const query = { $relation: 'UserTag', $fields: [ @@ -623,7 +625,26 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('r5[relation nested] - that has both role, and linkfield pointing to same role', async () => { + it('TODO{ST}:r4.alt[relation, nested, direct] - nested relation direct on relation', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Room', + $fields: ['id', { $path: 'bookings', $fields: ['id'] }, { $path: 'guests', $fields: ['id'] }], + }; + const expected = [ + { id: 'r1' }, + { id: 'r2', bookings: [{ id: 'b1' }], guests: [{ id: 'g1' }] }, + { id: 'r3', bookings: [{ id: 'b3' }], guests: [{ id: 'g3' }] }, + { id: 'r4' }, + { id: 'r5', bookings: [{ id: 'b2' }], guests: [{ id: 'g2' }] }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + + it('TODO{P}:r5[relation nested] - that has both role, and linkfield pointing to same role', async () => { + // Postgres: + // - Role field UserTagGroup.tagId cannot reference multiple records. const query = { $entity: 'Color', $fields: ['id', 'user-tags', 'group'], @@ -664,7 +685,26 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('r6[relation nested] - relation connected to relation and a tunneled relation', async () => { + it('TODO{ST}:r5.alt[relation nested] - that has both role, and linkfield pointing to same role', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Room', + $fields: ['id', 'bookings', 'guests'], + }; + const expected = [ + { id: 'r1' }, + { id: 'r2', bookings: ['b1'], guests: ['g1'] }, + { id: 'r3', bookings: ['b3'], guests: ['g3'] }, + { id: 'r4' }, + { id: 'r5', bookings: ['b2'], guests: ['g2'] }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + + it('TODO{P}:r6[relation nested] - relation connected to relation and a tunneled relation', async () => { + // Postgres: + // - Role field UserTagGroup.tagId and UserTag.tagId cannot reference multiple records. const query = { $relation: 'UserTag', }; @@ -716,7 +756,24 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('r7[relation, nested, direct] - nested on nested', async () => { + it('TODO{ST}:r6.alt[relation nested] - relation connected to relation and a tunneled relation', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { $relation: 'Room' }; + const expected = [ + { id: 'r1', pricePerNight: '150.00', isAvailable: true, hotel: 'h1' }, + { id: 'r2', pricePerNight: '200.00', isAvailable: false, hotel: 'h1', bookings: ['b1'], guests: ['g1'] }, + { id: 'r3', pricePerNight: '180.00', isAvailable: true, hotel: 'h2', bookings: ['b3'], guests: ['g3'] }, + { id: 'r4', pricePerNight: '220.00', isAvailable: true, hotel: 'h2' }, + { id: 'r5', pricePerNight: '100.00', isAvailable: false, hotel: 'h3', bookings: ['b2'], guests: ['g2'] }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + + it('TODO{P}:r7[relation, nested, direct] - nested on nested', async () => { + // Postgres: + // - Inherited entity/relation is not supported (God and SuperUser are inherited from Used). + // - Role field UserTagGroup.tagId cannot reference multiple records. const query = { $relation: 'UserTag', $fields: [ @@ -850,7 +907,31 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('r8[relation, nested, deep] - deep nested', async () => { + it('TODO{ST}:r7.alt[relation, nested, direct] - nested on nested', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Room', + $fields: [ + 'id', + { $path: 'bookings', $fields: ['id', 'guest'] }, + { $path: 'guests', $fields: ['id', 'bookings'] }, + ], + }; + const expected = [ + { id: 'r1' }, + { id: 'r2', bookings: [{ id: 'b1', guest: 'g1' }], guests: [{ id: 'g1', bookings: ['b1'] }] }, + { id: 'r3', bookings: [{ id: 'b3', guest: 'g3' }], guests: [{ id: 'g3', bookings: ['b3'] }] }, + { id: 'r4' }, + { id: 'r5', bookings: [{ id: 'b2', guest: 'g2' }], guests: [{ id: 'g2', bookings: ['b2'] }] }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + + it('TODO{P}:r8[relation, nested, deep] - deep nested', async () => { + // Postgres: + // - Inherited entity/relation is not supported (God and SuperUser are inherited from Used). + // - Role field UserTagGroup.tagId cannot reference multiple records. const query = { $entity: 'Space', $id: 'space-2', @@ -901,7 +982,6 @@ export const testQuery = createTest('Query', (ctx) => { }, }; const res = await ctx.query(query); - //console.log('res', res); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); @@ -913,7 +993,27 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('r9[relation, nested, ids]', async () => { + it('TODO{ST}:r8.alt[relation, nested, deep] - deep nested', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $id: 'h1', + $fields: [ + 'id', + { + $path: 'rooms', + $id: 'r2', + $fields: ['id', { $path: 'bookings', $fields: ['id', { $path: 'guest', $fields: ['id', 'bookings'] }] }], + }, + ], + }; + const expected = { id: 'h1', rooms: { id: 'r2', bookings: [{ id: 'b1', guest: { id: 'g1', bookings: ['b1'] } }] } }; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + + it('TODO{P}:r9[relation, nested, ids]', async () => { + // Postgres: Role field UserTagGroup.tagId cannot reference multiple records. const query = { $relation: 'UserTagGroup', $id: 'utg-1', @@ -932,6 +1032,18 @@ export const testQuery = createTest('Query', (ctx) => { }); }); + it('TODO{ST}:r9.alt[relation, nested, ids]', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $id: 'h1', + $fields: ['id', 'rooms'], + }; + const expected = { id: 'h1', rooms: ['r1', 'r2'] }; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + it('ef1[entity] - $id single', async () => { const wrongRes = await ctx.query({ $entity: 'User', $id: uuidv4() }); expect(wrongRes).toEqual(null); @@ -958,7 +1070,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('ef3[entity] - $fields single', async () => { + it('TODO{P}:ef3[entity] - $fields single', async () => { + // Postgres: Inherited entity/relation is not supported const res = await ctx.query({ $entity: 'User', $fields: ['id'] }); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); @@ -984,75 +1097,96 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); + it('TODO{ST}:ef3.alt[entity] - $fields single', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $fields: ['id'], + }; + const expected = [{ id: 'h1' }, { id: 'h2' }, { id: 'h3' }]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + it('ef4[entity] - $fields multiple', async () => { - const res = await ctx.query({ - $entity: 'User', - $id: 'user1', - $fields: ['name', 'email'], - }); + const res = await ctx.query( + { + $entity: 'User', + $id: 'user1', + $fields: ['id', 'name', 'email'], + }, + { noMetadata: true }, + ); expect(res).toEqual({ - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', email: 'antoine@test.com', }); }); it('ef5[entity,filter] - $filter single', async () => { - const res = await ctx.query({ - $entity: 'User', - $filter: { name: 'Antoine' }, - $fields: ['name'], - }); + const res = await ctx.query( + { + $entity: 'User', + $filter: { name: 'Antoine' }, + $fields: ['id', 'name'], + }, + { noMetadata: true }, + ); // notice now it is an array. Multiple users could be called Antoine - expect(res).toEqual([{ $thing: 'User', $thingType: 'entity', $id: 'user1', name: 'Antoine' }]); + expect(res).toEqual([{ id: 'user1', name: 'Antoine' }]); }); it('ef6[entity,filter,id] - $filter by id in filter', async () => { - const res = await ctx.query({ - $entity: 'User', - $filter: { id: 'user1' }, - $fields: ['name'], - }); - expect(res).toEqual({ $thing: 'User', $thingType: 'entity', $id: 'user1', name: 'Antoine' }); + const res = await ctx.query( + { + $entity: 'User', + $filter: { id: 'user1' }, + $fields: ['id', 'name'], + }, + { noMetadata: true }, + ); + expect(res).toEqual({ id: 'user1', name: 'Antoine' }); }); it('ef7[entity,unique] - $filter by unique field', async () => { - const res = await ctx.query({ - $entity: 'User', - $filter: { email: 'antoine@test.com' }, - $fields: ['name', 'email'], - }); + const res = await ctx.query( + { + $entity: 'User', + $filter: { email: 'antoine@test.com' }, + $fields: ['id', 'name', 'email'], + }, + { noMetadata: true }, + ); // and now its not an array again, we used at least one property in the filter that is either the single key specified in idFields: ['id'] or has a validations.unique:true expect(res).toEqual({ - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', email: 'antoine@test.com', }); }); it('n1[nested] Only ids', async () => { - const res = await ctx.query({ - $entity: 'User', - $id: 'user1', - $fields: ['name', 'accounts'], - }); + const res = await ctx.query( + { + $entity: 'User', + $id: 'user1', + $fields: ['id', 'name', 'accounts'], + }, + { noMetadata: true }, + ); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res)).toEqual({ - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', accounts: ['account1-1', 'account1-2', 'account1-3'], }); }); - it('n2[nested] First level all fields', async () => { + it('TODO{P}:n2[nested] First level all fields', async () => { + // Postgres: isSecureProvider is computed in the database const query = { $entity: 'User', $id: 'user1', @@ -1128,65 +1262,80 @@ export const testQuery = createTest('Query', (ctx) => { }); }); + it('TODO{ST}:n2.alt[nested] First level all fields', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $id: 'h1', + $fields: ['id', { $path: 'rooms' }], + }; + const expected = { + id: 'h1', + rooms: [ + { id: 'r1', pricePerNight: '150.00', isAvailable: true, hotel: 'h1' }, + { id: 'r2', pricePerNight: '200.00', isAvailable: false, hotel: 'h1', bookings: ['b1'], guests: ['g1'] }, + ], + }; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + it('n3[nested, $fields] First level filtered fields', async () => { - const res = await ctx.query({ - $entity: 'User', - $id: 'user1', - $fields: ['name', { $path: 'accounts', $fields: ['provider'] }], - }); + const res = await ctx.query( + { + $entity: 'User', + $id: 'user1', + $fields: ['id', 'name', { $path: 'accounts', $fields: ['id', 'provider'] }], + }, + { noMetadata: true }, + ); expect(res).toBeDefined(); expect(deepSort(res)).toEqual({ - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', accounts: [ - { $thing: 'Account', $thingType: 'entity', $id: 'account1-1', provider: 'google' }, - { $thing: 'Account', $thingType: 'entity', $id: 'account1-2', provider: 'facebook' }, - { $thing: 'Account', $thingType: 'entity', $id: 'account1-3', provider: 'github' }, + { id: 'account1-1', provider: 'google' }, + { id: 'account1-2', provider: 'facebook' }, + { id: 'account1-3', provider: 'github' }, ], }); }); it('n4a[nested, $id] Local filter on nested, by id', async () => { - const res = await ctx.query({ - $entity: 'User', - $id: ['user1', 'user2', 'user3'], - $fields: [ - 'name', - { - $path: 'accounts', - $id: 'account3-1', // id specified so nested children has to be an objec and not an array - $fields: ['provider'], - }, - ], - }); + const res = await ctx.query( + { + $entity: 'User', + $id: ['user1', 'user2', 'user3'], + $fields: [ + 'id', + 'name', + { + $path: 'accounts', + $id: 'account3-1', // id specified so nested children has to be an objec and not an array + $fields: ['id', 'provider'], + }, + ], + }, + { noMetadata: true }, + ); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res)).toEqual([ { - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', }, { - $thing: 'User', - $thingType: 'entity', - $id: 'user2', + id: 'user2', name: 'Loic', }, { - $thing: 'User', - $thingType: 'entity', - $id: 'user3', + id: 'user3', name: 'Ann', // accounts here has to be a single object, not an array because we specified an id in the nested query accounts: { - $thing: 'Account', - $thingType: 'entity', - $id: 'account3-1', + id: 'account3-1', provider: 'facebook', }, }, @@ -1194,38 +1343,37 @@ export const testQuery = createTest('Query', (ctx) => { }); it('n4b[nested, $id] Local filter on nested depth two, by id', async () => { - const res = await ctx.query({ - $entity: 'User', - $id: 'user1', - $fields: [ - { - $path: 'spaces', - $id: 'space-1', // id specified so nested children has to be an objec and not an array - $fields: [{ $path: 'users', $id: 'user1', $fields: ['$id'] }], - }, - ], - }); + const res = await ctx.query( + { + $entity: 'User', + $id: 'user1', + $fields: [ + 'id', + { + $path: 'spaces', + $id: 'space-1', // id specified so nested children has to be an objec and not an array + $fields: ['id', { $path: 'users', $id: 'user1', $fields: ['id'] }], + }, + ], + }, + { noMetadata: true }, + ); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res)).toEqual({ - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', spaces: { - $id: 'space-1', - $thing: 'Space', - $thingType: 'entity', + id: 'space-1', users: { - $id: 'user1', - $thing: 'User', - $thingType: 'entity', + id: 'user1', }, }, }); }); - it('nf1[nested, $filters] Local filter on nested, single id', async () => { + it('TODO{P}:nf1[nested, $filters] Local filter on nested, single id', async () => { + // Postgres: Computed data field (isSecureProvider) is not supported const res = await ctx.query({ $entity: 'User', $id: 'user1', @@ -1253,50 +1401,63 @@ export const testQuery = createTest('Query', (ctx) => { }); }); + it('TODO{ST}:nf1.alt[nested, $filters] Local filter on nested, single id', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $id: 'h1', + $fields: ['id', { $path: 'rooms', $filter: { isAvailable: true } }], + }; + const expected = { + id: 'h1', + rooms: [{ id: 'r1', pricePerNight: '150.00', isAvailable: true, hotel: 'h1' }], + }; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(res, 'id')).toEqual(expected); + }); + it('nf2[nested, $filters] Local filter on nested, by field, multiple sources, some are empty', async () => { - const res = await ctx.query({ - $entity: 'User', - $id: ['user1', 'user2', 'user3'], - $fields: [ - 'name', - { - $path: 'accounts', - $filter: { provider: 'google' }, - $fields: ['provider'], - }, - ], - }); + const res = await ctx.query( + { + $entity: 'User', + $id: ['user1', 'user2', 'user3'], + $fields: [ + 'name', + { + $path: 'accounts', + $filter: { provider: 'google' }, + $fields: ['provider'], + }, + ], + }, + { noMetadata: true }, + ); expect(res).toBeDefined(); expect(res).not.toBeInstanceOf(String); expect(deepSort(res)).toEqual([ { - $thing: 'User', - $thingType: 'entity', - $id: 'user1', + id: 'user1', name: 'Antoine', accounts: [ // array, we can't know it was only one - { $thing: 'Account', $thingType: 'entity', $id: 'account1-1', provider: 'google' }, + { id: 'account1-1', provider: 'google' }, ], }, { - $thing: 'User', - $thingType: 'entity', - $id: 'user2', + id: 'user2', name: 'Loic', - accounts: [{ $thing: 'Account', $thingType: 'entity', $id: 'account2-1', provider: 'google' }], + accounts: [{ id: 'account2-1', provider: 'google' }], }, { - $thing: 'User', - $thingType: 'entity', - $id: 'user3', + id: 'user3', name: 'Ann', }, ]); }); - it('nf3[nested, $filters] Local filter on nested, by link field, multiple sources', async () => { + it('TODO{P}:nf3[nested, $filters] Local filter on nested, by link field, multiple sources', async () => { + // Postgres: Filter by non-local field (UserTag.id) is not supported const res = await ctx.query({ $entity: 'Space', $fields: [ @@ -1355,7 +1516,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('nf4[nested, $filters] Local filter on nested, by link field, multiple sources', async () => { + it('TODO{P}:nf4[nested, $filters] Local filter on nested, by link field, multiple sources', async () => { + // Postgres: Filter by non-local field (Space-User.spaceId) is not supported const res = await ctx.query({ $relation: 'UserTag', $fields: [ @@ -1432,11 +1594,12 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('TODO{TS}:nf2a[nested, $filters] Nested filter for array of ids', async () => { + it('TODO{PTS}:nf2a[nested, $filters] Nested filter for array of ids', async () => { expect(true).toEqual(false); }); it('lf1[$filter] Filter by a link field with cardinality ONE', async () => { + // Postgres: Filter by role field (User-Account.userId) is not supported const res = await ctx.query( { $relation: 'User-Accounts', @@ -1449,6 +1612,7 @@ export const testQuery = createTest('Query', (ctx) => { }); it('lf2[$filter, $not] Filter out by a link field with cardinality ONE', async () => { + // Postgres: Filter by role field (User-Account.userId) is not supported const res = await ctx.query( { $relation: 'User-Accounts', @@ -1462,7 +1626,8 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(res, 'id')).toMatchObject([{ id: 'ua2-1' }, { id: 'ua3-1' }]); }); - it('lf3[$filter] Filter by a link field with cardinality MANY', async () => { + it('TODO{P}:lf3[$filter] Filter by a link field with cardinality MANY', async () => { + // Postgres: Filter by non-local field (Space-User.spaceId) is not supported const res = await ctx.query( { $entity: 'User', @@ -1474,7 +1639,7 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(res, 'id')).toMatchObject([{ id: 'user1' }, { id: 'user5' }]); }); - it('TODO{T}:lf4[$filter, $or] Filter by a link field with cardinality MANY', async () => { + it('TODO{PT}:lf4[$filter, $or] Filter by a link field with cardinality MANY', async () => { //!: FAILS IN TQL const res = await ctx.query( { @@ -1508,7 +1673,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('slo2[$sort, $limit, $offset] sub level', async () => { + it('TODO{P}:slo2[$sort, $limit, $offset] sub level', async () => { + // Postgres: sort, limit, and offset not at the root level is not supported const res = await ctx.query( { $entity: 'User', @@ -1536,7 +1702,7 @@ export const testQuery = createTest('Query', (ctx) => { }); }); - it('TODO{S}:slo3[$sort, $limit, $offset] with an empty attribute', async () => { + it('TODO{PS}:slo3[$sort, $limit, $offset] with an empty attribute', async () => { //! fails in SURQL const res = await ctx.query( { @@ -1588,7 +1754,7 @@ export const testQuery = createTest('Query', (ctx) => { }); }); - it('TODO{TS}:i2[inherited, attributes] Entity with inherited attributes should fetch them even when querying from parent class', async () => { + it('TODO{PTS}:i2[inherited, attributes] Entity with inherited attributes should fetch them even when querying from parent class', async () => { const res = await ctx.query({ $entity: 'User', $id: 'god1' }, { noMetadata: true }); expect(res).toEqual({ id: 'god1', @@ -1609,7 +1775,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('ex1[extends] Query where an object plays 3 different roles because it extends 2 types', async () => { + it('TODO{P}:ex1[extends] Query where an object plays 3 different roles because it extends 2 types', async () => { + // Postgres: Inherited relation (Self, Kind, and SpaceDef) is not supported /// note: fixed with an ugly workaround (getEntityName() in parseTQL.ts) const res = await ctx.query({ $entity: 'Space', $id: 'space-2' }, { noMetadata: true }); @@ -1625,7 +1792,8 @@ export const testQuery = createTest('Query', (ctx) => { }); }); - it('ex2[extends] Query of the parent', async () => { + it('TODO{P}:ex2[extends] Query of the parent', async () => { + // Postgres: Inherited relation (Self, Kind, and SpaceDef) is not supported /// note: fixed with an ugly workaround (getEntityName() in parseTQL.ts) const res = await ctx.query({ $entity: 'Space', $id: 'space-2', $fields: ['objects'] }, { noMetadata: true }); expect(deepSort(res, 'id')).toEqual({ @@ -1633,7 +1801,7 @@ export const testQuery = createTest('Query', (ctx) => { }); }); - it('TODO{TS}:re1[repeated] Query with repeated path, different nested ids', async () => { + it('TODO{PTS}:re1[repeated] Query with repeated path, different nested ids', async () => { const res = await ctx.query( { $entity: 'Space', @@ -1661,7 +1829,7 @@ export const testQuery = createTest('Query', (ctx) => { }); }); - it('TODO{TS}:re2[repeated] Query with repeated path, different nested patterns', async () => { + it('TODO{PTS}:re2[repeated] Query with repeated path, different nested patterns', async () => { const res = await ctx.query( { $entity: 'Space', @@ -1701,7 +1869,10 @@ export const testQuery = createTest('Query', (ctx) => { }); }); - it('xf2[excludedFields, deep] - deep nested', async () => { + it('TODO{P}:xf2[excludedFields, deep] - deep nested', async () => { + // Postgres: + // - Computed data field (isBlue) is not supported + // - Data field type flex (freeForAll) is not supported const query = { $entity: 'Space', $id: 'space-2', @@ -1764,7 +1935,40 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('xf3[excludedFields, deep] - Exclude virtual field', async () => { + it('TODO{ST}:xf2.alt[excludedFields, deep] - deep nested', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $id: 'h1', + $fields: [ + 'id', + { $path: 'rooms', $id: 'r2', $fields: ['id', { $path: 'bookings', $excludedFields: ['roomId'] }] }, + ], + }; + const expected = { + id: 'h1', + rooms: { + id: 'r2', + bookings: [ + { + id: 'b1', + checkIn: '2024-01-31T16:00:00.000Z', + checkOut: '2024-02-04T16:00:00.000Z', + status: 'checked-in', + totalCost: '800.00', + room: 'r2', + guest: 'g1', + payments: ['p1'], + }, + ], + }, + }; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(JSON.parse(JSON.stringify(res)), 'id')).toEqual(expected); + }); + + it('TODO{P}:xf3[excludedFields, deep] - Exclude virtual field', async () => { + // Postgres: Computed data field (freeForAll) is not supported const query = { $entity: 'User', $id: 'user2', @@ -1803,7 +2007,8 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(resWithoutMetadata, 'id')).toEqual(deepRemoveMetaData(expectedRes)); }); - it('vi1[virtual, attribute] Virtual DB field', async () => { + it('TODO{P}:vi1[virtual, attribute] Virtual DB field', async () => { + // Postgres: Computed data field (isSecureProvider) is not supported //This works with TypeDB rules const res = await ctx.query({ $entity: 'Account', $fields: ['id', 'isSecureProvider'] }, { noMetadata: true }); @@ -1831,7 +2036,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('vi2[virtual, edge] Virtual DB edge field', async () => { + it('TODO{P}:vi2[virtual, edge] Virtual DB edge field', async () => { + // Postgres: Computed link field (tagA and otherTags) is not supported //This works with TypeDB rules const res = await ctx.query({ $entity: 'Hook' }, { noMetadata: true }); @@ -1864,7 +2070,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('co1[computed] Virtual computed field', async () => { + it('TODO{P}:o1[computed] Virtual computed field', async () => { + // TODO: Computed field is not handled yet const res = await ctx.query( { $entity: 'Color', $id: ['blue', 'yellow'], $fields: ['id', 'isBlue'] }, { noMetadata: true }, @@ -1882,7 +2089,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('co2[computed] Computed virtual field depending on edge id', async () => { + it('TODO{P}:co2[computed] Computed virtual field depending on edge id', async () => { + // TODO: Computed field is not handled yet const res = await ctx.query( { $entity: 'Color', $id: ['blue', 'yellow'], $fields: ['id', 'user-tags', 'totalUserTags'] }, { noMetadata: true }, @@ -1902,7 +2110,7 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('TODO{TS}:co3[computed], Computed virtual field depending on edge id, missing dependencies', async () => { + it('TODO{PTS}:co3[computed], Computed virtual field depending on edge id, missing dependencies', async () => { const res = await ctx.query( { $entity: 'Color', $id: ['blue', 'yellow'], $fields: ['id', 'totalUserTags'] }, { noMetadata: true }, @@ -1920,7 +2128,8 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('mv1[multiVal, query, ONE], get multiVal', async () => { + it('TODO{P}:mv1[multiVal, query, ONE], get multiVal', async () => { + // Postgres: Data field type flex is not supported. const res = await ctx.query({ $entity: 'Color', $fields: ['id', 'freeForAll'] }, { noMetadata: true }); expect(deepSort(res, 'id')).toEqual([ @@ -1939,7 +2148,7 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('TODO{T}:mv2[multiVal, query, ONE], filter by multiVal', async () => { + it('TODO{PT}:mv2[multiVal, query, ONE], filter by multiVal', async () => { const res = await ctx.query( { $entity: 'Color', $filter: { freeForAll: 'hey' }, $fields: ['id', 'freeForAll'] }, { noMetadata: true }, @@ -2092,7 +2301,8 @@ export const testQuery = createTest('Query', (ctx) => { // NESTED - it('a1[$as] - as for attributes and roles and links', async () => { + it('TODO{P}:a1[$as] - as for attributes and roles and links', async () => { + // Postgres: Role field UserTagGroup.tagId cannot references multiple records. const expectedRes = { 'email_as': 'antoine@test.com', 'id': 'user1', @@ -2143,6 +2353,35 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(res, 'id')).toEqual(expectedRes); }); + it('TODO{ST}:a1.alt[$as] - as for attributes and roles and links', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Booking', + $id: 'b1', + $fields: [ + 'id', + { $path: 'status', $as: 'currentStatus' }, + { $path: 'payments', $as: 'allPayments' }, + { $path: 'guest', $as: 'currentGuest' }, + ], + }; + const expected = { + id: 'b1', + currentStatus: 'checked-in', + currentGuest: { + id: 'g1', + name: 'John Doe', + email: 'john.doe@example.com', + phone: '123-456-7890', + bookings: ['b1'], + rooms: ['r2'], + }, + allPayments: [{ id: 'p1', amountPaid: '800.00', paymentDate: '2024-02-01T06:30:00.000Z', booking: 'b1' }], + }; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(JSON.parse(JSON.stringify(res)), 'id')).toEqual(expected); + }); + it('bq1[batched query] - as for attributes and roles and links', async () => { const expectedRes = [ { @@ -2193,7 +2432,7 @@ export const testQuery = createTest('Query', (ctx) => { expect((entity as any).profile).toBeUndefined(); }); - it('TODO{TS}:bq2[batched query with $as] - as for attributes and roles and links', async () => { + it('TODO{PTS}:bq2[batched query with $as] - as for attributes and roles and links', async () => { const expectedRes = { users: { id: 'user1', @@ -2225,7 +2464,8 @@ export const testQuery = createTest('Query', (ctx) => { expect(res).toEqual(expectedRes); }); - it('dn1[deep nested] ridiculously deep nested query', async () => { + it('TODO{P}:dn1[deep nested] ridiculously deep nested query', async () => { + // Postgres: Role field UserTagGroup.tagId cannot references multiple records. const res = await ctx.query({ $entity: 'Color', $fields: [ @@ -2661,7 +2901,40 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('TODO{T}:dn2[deep numbers] Big numbers', async () => { + it('TODO{ST}:dn1.alt[deep nested] ridiculously deep nested query', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Hotel', + $fields: [ + 'id', + { + $path: 'rooms', + $fields: [ + 'id', + { + $path: 'bookings', + $fields: [ + 'id', + { + $path: 'payments', + $fields: ['id'], + }, + ], + }, + ], + }, + ], + }; + const expected = [ + { id: 'h1', rooms: [{ id: 'r1' }, { id: 'r2', bookings: [{ id: 'b1', payments: [{ id: 'p1' }] }] }] }, + { id: 'h2', rooms: [{ id: 'r3', bookings: [{ id: 'b3', payments: [{ id: 'p2' }] }] }, { id: 'r4' }] }, + { id: 'h3', rooms: [{ id: 'r5', bookings: [{ id: 'b2' }] }] }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(JSON.parse(JSON.stringify(res)), 'id')).toEqual(expected); + }); + + it('TODO{PT}:dn2[deep numbers] Big numbers', async () => { const res = await ctx.query( { $entity: 'Company', @@ -2689,7 +2962,7 @@ export const testQuery = createTest('Query', (ctx) => { ]); }); - it('TODO{T}:dn3[deep numbers] Big numbers nested', async () => { + it('TODO{PT}:dn3[deep numbers] Big numbers nested', async () => { const res = await ctx.query( { $entity: 'Company', @@ -2768,7 +3041,8 @@ export const testQuery = createTest('Query', (ctx) => { expect(deepSort(res, 'id')).toEqual([{ id: 'user4', name: 'Ben' }]); }); - it('fk2[filter, keywords, exists], filter by undefined/null property', async () => { + it('TODO{P}:fk2[filter, keywords, exists], filter by undefined/null property', async () => { + // Postgres: Inherited entity (God and SuperUser) is not supported const res = await ctx.query({ $entity: 'User', $filter: { email: { $exists: true } } }, { noMetadata: true }); expect(deepSort(res, 'id')).toEqual([ @@ -2814,4 +3088,34 @@ export const testQuery = createTest('Query', (ctx) => { }, ]); }); + + it('TODO{ST}:fk2[filter, keywords, exists], filter by undefined/null property', async () => { + // Surreal & TypeDB: The schema and the data are not added yet + const query = { + $relation: 'Guest', + $filter: { + phone: { $exists: true }, + }, + }; + const expected = [ + { + id: 'g1', + name: 'John Doe', + email: 'john.doe@example.com', + phone: '123-456-7890', + bookings: ['b1'], + rooms: ['r2'], + }, + { + id: 'g2', + name: 'Jane Smith', + email: 'jane.smith@example.com', + phone: '987-654-3210', + bookings: ['b2'], + rooms: ['r5'], + }, + ]; + const res = await ctx.query(query, { noMetadata: true }); + expect(deepSort(JSON.parse(JSON.stringify(res)), 'id')).toEqual(expected); + }); });