diff --git a/.changeset/light-worms-switch.md b/.changeset/light-worms-switch.md new file mode 100644 index 0000000000..b4fcce8a3c --- /dev/null +++ b/.changeset/light-worms-switch.md @@ -0,0 +1,7 @@ +--- +'@directus/data-driver-postgres': minor +'@directus/data-sql': minor +'@directus/data': minor +--- + +Added more modifiers to the data abstraction and m2o join diff --git a/packages/data-driver-postgres/.dependency-cruiser.cjs b/packages/data-driver-postgres/.dependency-cruiser.cjs new file mode 100644 index 0000000000..4898e47273 --- /dev/null +++ b/packages/data-driver-postgres/.dependency-cruiser.cjs @@ -0,0 +1,435 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: 'no-circular', + severity: 'warn', + comment: + 'This dependency is part of a circular relationship. You might want to revise ' + + 'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', + from: {}, + to: { + circular: true, + }, + }, + { + name: 'no-orphans', + comment: + "This is an orphan module - it's likely not used (anymore?). Either use it or " + + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " + + 'add an exception for it in your dependency-cruiser configuration. By default ' + + 'this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration ' + + 'files (.d.ts), tsconfig.json and some of the babel and webpack configs.', + severity: 'warn', + from: { + orphan: true, + pathNot: [ + '(^|/).[^/]+.(js|cjs|mjs|ts|json)$', // dot files + '.d.ts$', // TypeScript declaration files + '(^|/)tsconfig.json$', // TypeScript config + '(^|/)(babel|webpack).config.(js|cjs|mjs|ts|json)$', // other configs + ], + }, + to: {}, + }, + { + name: 'no-deprecated-core', + comment: + 'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + + "bound to exist - node doesn't deprecate lightly.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['core'], + path: [ + '^(v8/tools/codemap)$', + '^(v8/tools/consarray)$', + '^(v8/tools/csvparser)$', + '^(v8/tools/logreader)$', + '^(v8/tools/profile_view)$', + '^(v8/tools/profile)$', + '^(v8/tools/SourceMap)$', + '^(v8/tools/splaytree)$', + '^(v8/tools/tickprocessor-driver)$', + '^(v8/tools/tickprocessor)$', + '^(node-inspect/lib/_inspect)$', + '^(node-inspect/lib/internal/inspect_client)$', + '^(node-inspect/lib/internal/inspect_repl)$', + '^(async_hooks)$', + '^(punycode)$', + '^(domain)$', + '^(constants)$', + '^(sys)$', + '^(_linklist)$', + '^(_stream_wrap)$', + ], + }, + }, + { + name: 'not-to-deprecated', + comment: + 'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + + 'version of that module, or find an alternative. Deprecated modules are a security risk.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['deprecated'], + }, + }, + { + name: 'no-non-package-json', + severity: 'error', + comment: + "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + + "That's problematic as the package either (1) won't be available on live (2 - worse) will be " + + 'available on live with an non-guaranteed version. Fix it by adding the package to the dependencies ' + + 'in your package.json.', + from: {}, + to: { + dependencyTypes: ['npm-no-pkg', 'npm-unknown'], + }, + }, + { + name: 'not-to-unresolvable', + comment: + "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + + 'module: add it to your package.json. In all other cases you likely already know what to do.', + severity: 'error', + from: {}, + to: { + couldNotResolve: true, + }, + }, + { + name: 'no-duplicate-dep-types', + comment: + "Likely this module depends on an external ('npm') package that occurs more than once " + + 'in your package.json i.e. bot as a devDependencies and in dependencies. This will cause ' + + 'maintenance problems later on.', + severity: 'warn', + from: {}, + to: { + moreThanOneDependencyType: true, + // as it's pretty common to have a type import be a type only import + // _and_ (e.g.) a devDependency - don't consider type-only dependency + // types for this rule + dependencyTypesNot: ['type-only'], + }, + }, + + /* rules you might want to tweak for your specific situation: */ + + { + name: 'not-to-spec', + comment: + 'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + + "If there's something in a spec that's of use to other modules, it doesn't have that single " + + 'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', + severity: 'error', + from: {}, + to: { + path: '.(spec|test).(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee.md)$', + }, + }, + { + name: 'not-to-dev-dep', + severity: 'error', + comment: + "This module depends on an npm package from the 'devDependencies' section of your " + + 'package.json. It looks like something that ships to production, though. To prevent problems ' + + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + + 'section of your package.json. If this module is development only - add it to the ' + + 'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', + from: { + path: '^(src)', + pathNot: '.(spec|test).(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee.md)$', + }, + to: { + dependencyTypes: ['npm-dev'], + }, + }, + { + name: 'optional-deps-used', + severity: 'info', + comment: + 'This module depends on an npm package that is declared as an optional dependency ' + + "in your package.json. As this makes sense in limited situations only, it's flagged here. " + + "If you're using an optional dependency here by design - add an exception to your" + + 'dependency-cruiser configuration.', + from: {}, + to: { + dependencyTypes: ['npm-optional'], + }, + }, + { + name: 'peer-deps-used', + comment: + 'This module depends on an npm package that is declared as a peer dependency ' + + 'in your package.json. This makes sense if your package is e.g. a plugin, but in ' + + 'other cases - maybe not so much. If the use of a peer dependency is intentional ' + + 'add an exception to your dependency-cruiser configuration.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['npm-peer'], + }, + }, + ], + options: { + /* conditions specifying which files not to follow further when encountered: + - path: a regular expression to match + - dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/main/doc/rules-reference.md#dependencytypes-and-dependencytypesnot + for a complete list + */ + doNotFollow: { + path: 'node_modules', + }, + + /* conditions specifying which dependencies to exclude + - path: a regular expression to match + - dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies. + leave out if you want to exclude neither (recommended!) + */ + // exclude : { + // path: '', + // dynamic: true + // }, + + /* pattern specifying which files to include (regular expression) + dependency-cruiser will skip everything not matching this pattern + */ + // includeOnly : '', + + /* dependency-cruiser will include modules matching against the focus + regular expression in its output, as well as their neighbours (direct + dependencies and dependents) + */ + // focus : '', + + /* list of module systems to cruise */ + // moduleSystems: ['amd', 'cjs', 'es6', 'tsd'], + + /* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/' + to open it on your online repo or `vscode://file/${process.cwd()}/` to + open it in visual studio code), + */ + // prefix: '', + + /* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation + true: also detect dependencies that only exist before typescript-to-javascript compilation + "specify": for each dependency identify whether it only exists before compilation or also after + */ + tsPreCompilationDeps: true, + + /* + list of extensions to scan that aren't javascript or compile-to-javascript. + Empty by default. Only put extensions in here that you want to take into + account that are _not_ parsable. + */ + // extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"], + + /* if true combines the package.jsons found from the module up to the base + folder the cruise is initiated from. Useful for how (some) mono-repos + manage dependencies & dependency definitions. + */ + // combinedDependencies: false, + + /* if true leave symlinks untouched, otherwise use the realpath */ + // preserveSymlinks: false, + + /* TypeScript project file ('tsconfig.json') to use for + (1) compilation and + (2) resolution (e.g. with the paths property) + + The (optional) fileName attribute specifies which file to take (relative to + dependency-cruiser's current working directory). When not provided + defaults to './tsconfig.json'. + */ + tsConfig: { + fileName: 'tsconfig.json', + }, + + /* Webpack configuration to use to get resolve options from. + + The (optional) fileName attribute specifies which file to take (relative + to dependency-cruiser's current working directory. When not provided defaults + to './webpack.conf.js'. + + The (optional) `env` and `arguments` attributes contain the parameters to be passed if + your webpack config is a function and takes them (see webpack documentation + for details) + */ + // webpackConfig: { + // fileName: 'webpack.config.js', + // env: {}, + // arguments: {} + // }, + + /* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use + for compilation (and whatever other naughty things babel plugins do to + source code). This feature is well tested and usable, but might change + behavior a bit over time (e.g. more precise results for used module + systems) without dependency-cruiser getting a major version bump. + */ + // babelConfig: { + // fileName: '.babelrc', + // }, + + /* List of strings you have in use in addition to cjs/ es6 requires + & imports to declare module dependencies. Use this e.g. if you've + re-declared require, use a require-wrapper or use window.require as + a hack. + */ + // exoticRequireStrings: [], + /* options to pass on to enhanced-resolve, the package dependency-cruiser + uses to resolve module references to disk. You can set most of these + options in a webpack.conf.js - this section is here for those + projects that don't have a separate webpack config file. + + Note: settings in webpack.conf.js override the ones specified here. + */ + enhancedResolveOptions: { + /* List of strings to consider as 'exports' fields in package.json. Use + ['exports'] when you use packages that use such a field and your environment + supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack). + + If you have an `exportsFields` attribute in your webpack config, that one + will have precedence over the one specified here. + */ + exportsFields: ['exports'], + /* List of conditions to check for in the exports field. e.g. use ['imports'] + if you're only interested in exposed es6 modules, ['require'] for commonjs, + or all conditions at once `(['import', 'require', 'node', 'default']`) + if anything goes for you. Only works when the 'exportsFields' array is + non-empty. + + If you have a 'conditionNames' attribute in your webpack config, that one will + have precedence over the one specified here. + */ + conditionNames: ['import', 'require', 'node', 'default'], + /* + The extensions, by default are the same as the ones dependency-cruiser + can access (run `npx depcruise --info` to see which ones that are in + _your_ environment. If that list is larger than what you need (e.g. + it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use + TypeScript you can pass just the extensions you actually use (e.g. + [".js", ".jsx"]). This can speed up the most expensive step in + dependency cruising (module resolution) quite a bit. + */ + // extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"], + /* + If your TypeScript project makes use of types specified in 'types' + fields in package.jsons of external dependencies, specify "types" + in addition to "main" in here, so enhanced-resolve (the resolver + dependency-cruiser uses) knows to also look there. You can also do + this if you're not sure, but still use TypeScript. In a future version + of dependency-cruiser this will likely become the default. + */ + mainFields: ['main', 'types', 'typings'], + }, + reporterOptions: { + dot: { + /* pattern of modules that can be consolidated in the detailed + graphical dependency graph. The default pattern in this configuration + collapses everything in node_modules to one folder deep so you see + the external modules, but not the innards your app depends upon. + */ + collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + don't worry - dependency-cruiser will fall back to the default one. + */ + // theme: { + // graph: { + // /* use splines: "ortho" for straight lines. Be aware though + // graphviz might take a long time calculating ortho(gonal) + // routings. + // */ + // splines: "true" + // }, + // modules: [ + // { + // criteria: { matchesFocus: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesFocus: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { matchesReaches: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesReaches: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { source: "^src/model" }, + // attributes: { fillcolor: "#ccccff" } + // }, + // { + // criteria: { source: "^src/view" }, + // attributes: { fillcolor: "#ccffcc" } + // }, + // ], + // dependencies: [ + // { + // criteria: { "rules[0].severity": "error" }, + // attributes: { fontcolor: "red", color: "red" } + // }, + // { + // criteria: { "rules[0].severity": "warn" }, + // attributes: { fontcolor: "orange", color: "orange" } + // }, + // { + // criteria: { "rules[0].severity": "info" }, + // attributes: { fontcolor: "blue", color: "blue" } + // }, + // { + // criteria: { resolved: "^src/model" }, + // attributes: { color: "#0000ff77" } + // }, + // { + // criteria: { resolved: "^src/view" }, + // attributes: { color: "#00770077" } + // } + // ] + // } + }, + archi: { + /* pattern of modules that can be consolidated in the high level + graphical dependency graph. If you use the high level graphical + dependency graph reporter (`archi`) you probably want to tweak + this collapsePattern to your situation. + */ + collapsePattern: '^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + for 'archi' dependency-cruiser will use the one specified in the + dot section (see above), if any, and otherwise use the default one. + */ + // theme: { + // }, + }, + text: { + highlightFocused: true, + }, + }, + }, +}; +// generated: dependency-cruiser@13.1.4 on 2023-08-23T10:07:30.381Z diff --git a/packages/data-driver-postgres/package.json b/packages/data-driver-postgres/package.json index eb33b9b008..65120adb75 100644 --- a/packages/data-driver-postgres/package.json +++ b/packages/data-driver-postgres/package.json @@ -23,7 +23,8 @@ "scripts": { "build": "tsup src/index.ts --format=esm --dts", "dev": "tsup src/index.ts --format=esm --dts --watch", - "test": "vitest --watch=false" + "test": "vitest --watch=false", + "depcruise": "depcruise src --include-only '^src' -x test.ts --output-type dot | dot -T svg > dependency-graph.svg" }, "devDependencies": { "@directus/data": "workspace:*", @@ -31,7 +32,9 @@ "@directus/tsconfig": "workspace:*", "@types/node": "18.16.12", "@types/pg": "8.6.6", + "@types/wellknown": "0.5.4", "@vitest/coverage-c8": "0.31.1", + "dependency-cruiser": "13.1.4", "tsup": "7.2.0", "typescript": "5.2.2", "vitest": "0.31.1" @@ -39,6 +42,7 @@ "dependencies": { "@directus/data-sql": "workspace:*", "pg": "8.10.0", - "pg-query-stream": "^4.5.0" + "pg-query-stream": "4.5.0", + "wellknown": "0.5.0" } } diff --git a/packages/data-driver-postgres/readme.md b/packages/data-driver-postgres/readme.md index 9cbd2e6651..67daadb97d 100644 --- a/packages/data-driver-postgres/readme.md +++ b/packages/data-driver-postgres/readme.md @@ -1,3 +1,8 @@ # `@directus/data-driver-postgres` -Data abstraction for Postgres +This package converts an abstract query to an actual PostgreSQL statement und queries the database. + +## Current architecture of this package + +To get an overview of how the package is organized regarding it's files, directories and the dependencies between them, +run `pnpm run depcruise` and have a look in the created `dependency-graph.svg` image. diff --git a/packages/data-driver-postgres/src/index.test.ts b/packages/data-driver-postgres/src/index.test.ts new file mode 100644 index 0000000000..d7fda9fd78 --- /dev/null +++ b/packages/data-driver-postgres/src/index.test.ts @@ -0,0 +1,217 @@ +import type { AbstractQuery } from '@directus/data'; +import { randomIdentifier } from '@directus/random'; +import { expect, test, describe, vi, afterEach } from 'vitest'; +import DataDriverPostgres from './index.js'; +import type { AbstractSqlQuery } from '@directus/data-sql'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const randomCollection = randomIdentifier(); +const randomCollectionToJoin = randomIdentifier(); +const firstField = randomIdentifier(); +const firstFieldId = randomIdentifier(); +const secondField = randomIdentifier(); +const secondFieldId = randomIdentifier(); +const secondFieldAlias = randomIdentifier(); +const joinField1 = randomIdentifier(); +const joinFieldId = randomIdentifier(); +const joinField1Alias = randomIdentifier(); +const joinField2 = randomIdentifier(); +const collectionToJoinId = randomIdentifier(); +const joinField2Id = randomIdentifier(); +const fk = randomIdentifier(); +const foreignPk = randomIdentifier(); +const joinAlias = randomIdentifier(); + +describe('querying the driver', () => { + test('test', async () => { + /** + * the function 'convertToAbstractSqlQueryAndGenerateAliases' needs to me mocked. + * Otherwise we wouldn't know the generated aliases and hence the db response. + */ + vi.mock('@directus/data-sql', async () => { + // import the actual package, but replace one function with a mock (partial mocking) + const actual: any = await vi.importActual('@directus/data-sql'); + + return { + ...actual, + convertQuery: vi.fn().mockImplementation(() => { + const sqlQuery: AbstractSqlQuery = { + clauses: { + select: [ + { + type: 'primitive', + table: randomCollection, + column: firstField, + as: firstFieldId, + }, + { + type: 'primitive', + table: randomCollection, + column: secondField, + as: secondFieldId, + alias: secondFieldAlias, + }, + { + type: 'primitive', + table: collectionToJoinId, + column: joinField1, + as: joinFieldId, + alias: joinField1Alias, + }, + { + type: 'primitive', + table: collectionToJoinId, + column: joinField2, + as: joinField2Id, + }, + ], + from: randomCollection, + joins: [ + { + type: 'join', + table: randomCollectionToJoin, + as: collectionToJoinId, + on: { + type: 'condition', + negate: false, + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: randomCollection, + column: fk, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: collectionToJoinId, + column: foreignPk, + }, + }, + }, + alias: joinAlias, + }, + ], + }, + parameters: [], + aliasMapping: new Map([ + [firstFieldId, [firstField]], + [secondFieldId, [secondFieldAlias]], + [joinFieldId, [randomCollectionToJoin, joinField1Alias]], + [joinField2Id, [randomCollectionToJoin, joinField2]], + ]), + }; + + return sqlQuery; + }), + }; + }); + + const query: AbstractQuery = { + root: true, + collection: randomCollection, + store: 'randomDataStore1', + fields: [ + { + type: 'primitive', + field: firstField, + }, + { + type: 'primitive', + field: secondField, + alias: secondFieldAlias, + }, + { + type: 'm2o', + fields: [ + { + type: 'primitive', + field: joinField1, + alias: joinField1Alias, + }, + { + type: 'primitive', + field: joinField2, + }, + ], + join: { + current: { + fields: [fk], + }, + external: { + store: 'randomDataStore1', + collection: randomCollectionToJoin, + fields: [foreignPk], + }, + }, + alias: joinAlias, + }, + ], + }; + + const driver = new DataDriverPostgres({ + connectionString: 'postgres://postgres:postgres@localhost:5432/postgres', + }); + + vi.spyOn(driver, 'getDataFromSource').mockReturnValue({ + // @ts-ignore a promise is normally been returned + client: null, + stream: new ReadableStream({ + start(controller) { + const mockedData = [ + { + [firstFieldId]: 937, + [secondFieldId]: 'lorem ipsum', + [joinFieldId]: 42, + [joinField2Id]: true, + }, + { + [firstFieldId]: 1342, + [secondFieldId]: 'ipsum dapsum', + [joinFieldId]: 26, + [joinField2Id]: true, + }, + ]; + + mockedData.forEach((chunk) => controller.enqueue(chunk)); + }, + }), + }); + + const readableStream = await driver.query(query); + const actualResult: Record[] = []; + + for await (const chunk of readableStream) { + actualResult.push(chunk); + + // this is a hot fix. for some reason the mocked db response stream does not close properly + if (actualResult.length === 2) { + break; + } + } + + const expectedResult = [ + { + [firstField]: 937, + [secondFieldAlias]: 'lorem ipsum', + [randomCollectionToJoin]: { + [joinField1Alias]: 42, + [joinField2]: true, + }, + }, + { + [firstField]: 1342, + [secondFieldAlias]: 'ipsum dapsum', + [randomCollectionToJoin]: { + [joinField1Alias]: 26, + [joinField2]: true, + }, + }, + ]; + + expect(actualResult).toEqual(expectedResult); + }); +}); diff --git a/packages/data-driver-postgres/src/index.ts b/packages/data-driver-postgres/src/index.ts index 21189c73d7..a3fd3a3dcd 100644 --- a/packages/data-driver-postgres/src/index.ts +++ b/packages/data-driver-postgres/src/index.ts @@ -1,15 +1,17 @@ /** * The driver for PostgreSQL which can be registered by using @directus/data. * - * @packageDocumentation + * @packageDocumentation */ - import type { AbstractQuery, DataDriver } from '@directus/data'; -import { convertAbstractQueryToAbstractSqlQuery } from '@directus/data-sql'; -import type { Readable } from 'node:stream'; -import { Pool } from 'pg'; +import { convertQuery, getOrmTransformer, type ParameterizedSqlStatement } from '@directus/data-sql'; +import type { ReadableStream } from 'node:stream/web'; +import type { PoolClient } from 'pg'; +import pg from 'pg'; +import { convertToActualStatement } from './query/index.js'; import QueryStream from 'pg-query-stream'; -import { constructSqlQuery } from './query/index.js'; +import { Readable } from 'node:stream'; +import { convertParameters } from './query/parameters.js'; export interface DataDriverPostgresConfig { connectionString: string; @@ -17,12 +19,12 @@ export interface DataDriverPostgresConfig { export default class DataDriverPostgres implements DataDriver { #config: DataDriverPostgresConfig; - #pool: Pool; + #pool: pg.Pool; constructor(config: DataDriverPostgresConfig) { this.#config = config; - this.#pool = new Pool({ + this.#pool = new pg.Pool({ connectionString: this.#config.connectionString, }); } @@ -31,14 +33,35 @@ export default class DataDriverPostgres implements DataDriver { await this.#pool.end(); } - async query(query: AbstractQuery): Promise { + async getDataFromSource(pool: pg.Pool, sql: ParameterizedSqlStatement): Promise<{ poolClient: any; stream: any }> { + const poolClient: PoolClient = await pool.connect(); + const queryStream = new QueryStream(sql.statement, sql.parameters); + const stream = poolClient.query(queryStream); + stream.on('end', () => poolClient?.release()); + const webStream = Readable.toWeb(stream); + + return { + poolClient, + stream: webStream, + }; + } + + async query(query: AbstractQuery): Promise { + let client: PoolClient | null = null; + try { - const abstractSqlQuery = convertAbstractQueryToAbstractSqlQuery(query); - const sql = constructSqlQuery(abstractSqlQuery); - const queryStream = new QueryStream(sql.statement, sql.parameters); - return this.#pool.query(queryStream); + const conversionResult = convertQuery(query); + const statement = convertToActualStatement(conversionResult.clauses); + const parameters = convertParameters(conversionResult.parameters); + + const { poolClient, stream } = await this.getDataFromSource(this.#pool, { statement, parameters }); + client = poolClient; + + const ormTransformer = getOrmTransformer(conversionResult.aliasMapping); + return stream.pipeThrough(ormTransformer); } catch (err) { - throw new Error('Could not query the PostgreSQL datastore: ' + err); + client?.release(); + throw new Error('Failed to perform the query: ' + err); } } } diff --git a/packages/data-driver-postgres/src/query/from.test.ts b/packages/data-driver-postgres/src/query/from.test.ts index 0636c237e2..4ffac9057c 100644 --- a/packages/data-driver-postgres/src/query/from.test.ts +++ b/packages/data-driver-postgres/src/query/from.test.ts @@ -1,22 +1,17 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { beforeEach, expect, test } from 'vitest'; import { from } from './from.js'; import { randomIdentifier } from '@directus/random'; -let sample: { - statement: AbstractSqlQuery; -}; +let sample: AbstractSqlClauses; beforeEach(() => { sample = { - statement: { - select: [], - from: randomIdentifier(), - parameters: [], - }, + select: [], + from: randomIdentifier(), }; }); test('Returns parameterized FROM with escaped identifier', () => { - expect(from(sample.statement)).toStrictEqual(`FROM "${sample.statement.from}"`); + expect(from(sample)).toStrictEqual(`FROM "${sample.from}"`); }); diff --git a/packages/data-driver-postgres/src/query/from.ts b/packages/data-driver-postgres/src/query/from.ts index 64d1cef894..42a04eae03 100644 --- a/packages/data-driver-postgres/src/query/from.ts +++ b/packages/data-driver-postgres/src/query/from.ts @@ -1,4 +1,4 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { escapeIdentifier } from '../utils/escape-identifier.js'; /** @@ -6,6 +6,6 @@ import { escapeIdentifier } from '../utils/escape-identifier.js'; * @param from - The table to select data from * @returns The `FROM x` part of a SQL statement */ -export function from({ from }: AbstractSqlQuery): string { +export function from({ from }: AbstractSqlClauses): string { return `FROM ${escapeIdentifier(from)}`; } diff --git a/packages/data-driver-postgres/src/query/index.test.ts b/packages/data-driver-postgres/src/query/index.test.ts index 8d042dbadd..a41f70aa74 100644 --- a/packages/data-driver-postgres/src/query/index.test.ts +++ b/packages/data-driver-postgres/src/query/index.test.ts @@ -1,63 +1,62 @@ import type { AbstractQueryFieldNodePrimitive } from '@directus/data'; -import type { AbstractSqlQuery, CompareValueNode } from '@directus/data-sql'; -import { randomIdentifier, randomInteger } from '@directus/random'; +import type { AbstractSqlClauses } from '@directus/data-sql'; +import { randomIdentifier } from '@directus/random'; import { beforeEach, expect, test } from 'vitest'; -import { constructSqlQuery } from './index.js'; +import { convertToActualStatement } from './index.js'; let sample: { - statement: AbstractSqlQuery; + clauses: AbstractSqlClauses; }; +let firstSelectTable: string; +let firstSelectColumn: string; +let secondSelectTable: string; +let secondSelectColumn: string; + beforeEach(() => { + firstSelectTable = randomIdentifier(); + firstSelectColumn = randomIdentifier(); + secondSelectTable = randomIdentifier(); + secondSelectColumn = randomIdentifier(); + sample = { - statement: { + clauses: { select: [ - { type: 'primitive', column: randomIdentifier(), table: randomIdentifier() }, - { type: 'primitive', column: randomIdentifier(), table: randomIdentifier() }, + { type: 'primitive', column: firstSelectColumn, table: firstSelectTable }, + { type: 'primitive', column: secondSelectColumn, table: secondSelectTable }, ], from: randomIdentifier(), - parameters: [], }, }; }); test('basic statement', () => { - expect(constructSqlQuery(sample.statement)).toEqual({ - statement: `SELECT "${sample.statement.select[0]!.table}"."${sample.statement.select[0]!.column}", "${ - sample.statement.select[1]!.table - }"."${sample.statement.select[1]!.column}" FROM "${sample.statement.from}";`, - parameters: [], - }); + expect(convertToActualStatement(sample.clauses)).toEqual( + `SELECT "${firstSelectTable}"."${firstSelectColumn}", "${secondSelectTable}"."${secondSelectColumn}" FROM "${sample.clauses.from}";` + ); }); test('statement with a limit', () => { - sample.statement.limit = { parameterIndex: 0 }; - sample.statement.parameters = [randomInteger(1, 100)]; + sample.clauses.limit = { type: 'value', parameterIndex: 0 }; - expect(constructSqlQuery(sample.statement)).toEqual({ - statement: `SELECT "${sample.statement.select[0]!.table}"."${sample.statement.select[0]!.column}", "${ - sample.statement.select[1]!.table - }"."${sample.statement.select[1]!.column}" FROM "${sample.statement.from}" LIMIT $1;`, - parameters: sample.statement.parameters, - }); + expect(convertToActualStatement(sample.clauses)).toEqual( + `SELECT "${firstSelectTable}"."${firstSelectColumn}", "${secondSelectTable}"."${secondSelectColumn}" FROM "${sample.clauses.from}" LIMIT $1;` + ); }); test('statement with limit and offset', () => { - sample.statement.limit = { parameterIndex: 0 }; - sample.statement.offset = { parameterIndex: 1 }; - sample.statement.parameters = [randomInteger(1, 100), randomInteger(1, 100)]; + sample.clauses.limit = { type: 'value', parameterIndex: 0 }; + sample.clauses.offset = { type: 'value', parameterIndex: 1 }; - expect(constructSqlQuery(sample.statement)).toEqual({ - statement: `SELECT "${sample.statement.select[0]!.table}"."${sample.statement.select[0]!.column}", "${ - sample.statement.select[1]!.table - }"."${sample.statement.select[1]!.column}" FROM "${sample.statement.from}" LIMIT $1 OFFSET $2;`, - parameters: sample.statement.parameters, - }); + expect(convertToActualStatement(sample.clauses)).toEqual( + `SELECT "${firstSelectTable}"."${firstSelectColumn}", "${secondSelectTable}"."${secondSelectColumn}" FROM "${sample.clauses.from}" LIMIT $1 OFFSET $2;` + ); }); test('statement with order', () => { - sample.statement.order = [ + sample.clauses.order = [ { + type: 'order', orderBy: { type: 'primitive', field: randomIdentifier(), @@ -66,57 +65,85 @@ test('statement with order', () => { }, ]; - expect(constructSqlQuery(sample.statement)).toEqual({ - statement: `SELECT "${sample.statement.select[0]!.table}"."${sample.statement.select[0]!.column}", "${ - sample.statement.select[1]!.table - }"."${sample.statement.select[1]!.column}" FROM "${sample.statement.from}" ORDER BY "${ - (sample.statement.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field - }" ASC;`, - parameters: sample.statement.parameters, - }); + expect(convertToActualStatement(sample.clauses)).toEqual( + `SELECT "${firstSelectTable}"."${firstSelectColumn}", "${secondSelectTable}"."${secondSelectColumn}" FROM "${ + sample.clauses.from + }" ORDER BY "${(sample.clauses.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field}" ASC;` + ); }); test('statement with all possible modifiers', () => { - sample.statement.limit = { parameterIndex: 0 }; - sample.statement.offset = { parameterIndex: 1 }; + sample.clauses.limit = { type: 'value', parameterIndex: 0 }; + sample.clauses.offset = { type: 'value', parameterIndex: 1 }; - sample.statement.where = { - type: 'condition', - operation: 'gt', - target: { - type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), - }, - compareTo: { - type: 'value', - parameterIndexes: [2], - }, - negation: false, + const firstConditionTable = randomIdentifier(); + const firstConditionColumn = randomIdentifier(); + const firstConditionParameterIndex = 2; + const secondConditionTable = randomIdentifier(); + const secondConditionColumn = randomIdentifier(); + const secondConditionParameterIndex = 3; + const orderField = randomIdentifier(); + + sample.clauses.where = { + type: 'logical', + operator: 'and', + negate: false, + childNodes: [ + { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: firstConditionTable, + column: firstConditionColumn, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: firstConditionParameterIndex, + }, + }, + negate: false, + }, + { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: secondConditionTable, + column: secondConditionColumn, + }, + operation: 'lt', + compareTo: { + type: 'value', + parameterIndex: secondConditionParameterIndex, + }, + }, + negate: false, + }, + ], }; - sample.statement.parameters = [randomInteger(1, 100), randomInteger(1, 100), randomInteger(1, 100)]; - - sample.statement.order = [ + sample.clauses.order = [ { + type: 'order', orderBy: { type: 'primitive', - field: randomIdentifier(), + field: orderField, }, direction: 'ASC', }, ]; - expect(constructSqlQuery(sample.statement)).toEqual({ - statement: `SELECT "${sample.statement.select[0]!.table}"."${sample.statement.select[0]!.column}", "${ - sample.statement.select[1]!.table - }"."${sample.statement.select[1]!.column}" FROM "${sample.statement.from}" WHERE "${ - sample.statement.where.target.table - }"."${sample.statement.where.target.column}" > $${ - (sample.statement.where.compareTo as CompareValueNode).parameterIndexes[0]! + 1 - } ORDER BY "${ - (sample.statement.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field - }" ASC LIMIT $1 OFFSET $2;`, - parameters: sample.statement.parameters, - }); + expect(convertToActualStatement(sample.clauses)).toEqual( + `SELECT "${firstSelectTable}"."${firstSelectColumn}", "${secondSelectTable}"."${secondSelectColumn}" FROM "${ + sample.clauses.from + }" WHERE "${firstConditionTable}"."${firstConditionColumn}" > $${ + firstConditionParameterIndex + 1 + } AND "${secondConditionTable}"."${secondConditionColumn}" < $${ + secondConditionParameterIndex + 1 + } ORDER BY "${orderField}" ASC LIMIT $1 OFFSET $2;` + ); }); diff --git a/packages/data-driver-postgres/src/query/index.ts b/packages/data-driver-postgres/src/query/index.ts index 4f547915fa..717aaa0b43 100644 --- a/packages/data-driver-postgres/src/query/index.ts +++ b/packages/data-driver-postgres/src/query/index.ts @@ -1,14 +1,14 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; -import type { ParameterizedSQLStatement } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { select } from './select.js'; import { from } from './from.js'; import { limit } from './limit.js'; import { offset } from './offset.js'; import { where } from './where.js'; import { orderBy } from './orderBy.js'; +import { join } from './join.js'; /** - * Constructs an actual PostgreSQL query statement from a given abstract SQL query. + * Constructs an actual PostgreSQL query statement clauses from a given abstract SQL query. * * @remarks * To create a PostgreSQL statement each part is constructed in a separate function. @@ -18,16 +18,13 @@ import { orderBy } from './orderBy.js'; * @param query - The abstract SQL statement * @returns An actual SQL query with parameters */ -export function constructSqlQuery(query: AbstractSqlQuery): ParameterizedSQLStatement { - const statementParts = [select, from, where, orderBy, limit, offset]; +export function convertToActualStatement(clauses: AbstractSqlClauses): string { + const statementParts = [select, from, join, where, orderBy, limit, offset]; const statement = `${statementParts - .map((part) => part(query)) + .map((part) => part(clauses)) .filter((p) => p !== null) .join(' ')};`; - return { - statement, - parameters: query.parameters, - }; + return statement; } diff --git a/packages/data-driver-postgres/src/query/join.test.ts b/packages/data-driver-postgres/src/query/join.test.ts new file mode 100644 index 0000000000..091b5c5737 --- /dev/null +++ b/packages/data-driver-postgres/src/query/join.test.ts @@ -0,0 +1,124 @@ +import { test, expect, beforeEach } from 'vitest'; +import { join } from './join.js'; +import { randomIdentifier } from '@directus/random'; +import type { AbstractSqlClauses } from '@directus/data-sql'; + +let sample: AbstractSqlClauses; +let targetTable: string; +let targetColumn: string; +let compareToTable: string; +let compareToColumn: string; +let alias: string; + +beforeEach(() => { + targetTable = randomIdentifier(); + targetColumn = randomIdentifier(); + compareToTable = randomIdentifier(); + compareToColumn = randomIdentifier(); + alias = randomIdentifier(); + + sample = { + select: [ + { + type: 'primitive', + column: randomIdentifier(), + table: randomIdentifier(), + as: randomIdentifier(), + }, + ], + from: randomIdentifier(), + joins: [ + { + type: 'join', + table: targetTable, + as: alias, + on: { + type: 'condition', + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: targetTable, + column: targetColumn, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: compareToTable, + column: compareToColumn, + }, + }, + negate: false, + }, + }, + ], + }; +}); + +test('With an alias', () => { + expect(join(sample)).toStrictEqual( + `LEFT JOIN "${targetTable}" "${alias}" ON "${targetTable}"."${targetColumn}" = "${compareToTable}"."${compareToColumn}"` + ); +}); + +test('With an alias', () => { + const targetTable2 = randomIdentifier(); + const targetColumn2 = randomIdentifier(); + const compareToTable2 = randomIdentifier(); + const compareToColumn2 = randomIdentifier(); + + sample.joins = [ + { + type: 'join', + table: targetTable, + as: alias, + on: { + type: 'logical', + operator: 'and', + negate: false, + childNodes: [ + { + type: 'condition', + negate: false, + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: targetTable, + column: targetColumn, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: compareToTable, + column: compareToColumn, + }, + }, + }, + { + type: 'condition', + negate: false, + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: targetTable2, + column: targetColumn2, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: compareToTable2, + column: compareToColumn2, + }, + }, + }, + ], + }, + }, + ]; + + expect(join(sample)).toStrictEqual( + `LEFT JOIN "${targetTable}" "${alias}" ON "${targetTable}"."${targetColumn}" = "${compareToTable}"."${compareToColumn}" AND "${targetTable2}"."${targetColumn2}" = "${compareToTable2}"."${compareToColumn2}"` + ); +}); diff --git a/packages/data-driver-postgres/src/query/join.ts b/packages/data-driver-postgres/src/query/join.ts new file mode 100644 index 0000000000..70a1e5eedb --- /dev/null +++ b/packages/data-driver-postgres/src/query/join.ts @@ -0,0 +1,23 @@ +import type { AbstractSqlClauses } from '@directus/data-sql'; +import { conditionString } from '../utils/conditions/index.js'; +import { escapeIdentifier } from '../utils/escape-identifier.js'; + +/** + * Generates `LEFT JOIN x ON y` part. + * @param query the whole abstract query + * @returns the JOIN part or null if there are no joins in the query + */ +export const join = ({ joins }: AbstractSqlClauses): string | null => { + if (joins === undefined || joins.length === 0) return null; + + let joinString = ''; + + for (const join of joins) { + const tableName = escapeIdentifier(join.table); + const alias = escapeIdentifier(join.as); + const joinCondition = conditionString(join.on); + joinString += `LEFT JOIN ${tableName} ${alias} ON ${joinCondition}`; + } + + return joinString; +}; diff --git a/packages/data-driver-postgres/src/query/limit.test.ts b/packages/data-driver-postgres/src/query/limit.test.ts index 89d93bfb33..a2c94b5ff2 100644 --- a/packages/data-driver-postgres/src/query/limit.test.ts +++ b/packages/data-driver-postgres/src/query/limit.test.ts @@ -1,37 +1,22 @@ import { test, expect, beforeEach } from 'vitest'; import { limit } from './limit.js'; -import { randomInteger, randomIdentifier } from '@directus/random'; -import type { AbstractSqlQuery } from '@directus/data-sql'; +import { randomIdentifier } from '@directus/random'; +import type { AbstractSqlClauses } from '@directus/data-sql'; -let sample: { - statement: AbstractSqlQuery; -}; +let sample: AbstractSqlClauses; beforeEach(() => { sample = { - statement: { - select: [ - { - type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), - as: randomIdentifier(), - }, - { type: 'primitive', column: randomIdentifier(), table: randomIdentifier() }, - ], - from: randomIdentifier(), - parameters: [], - }, + select: [], + from: randomIdentifier(), }; }); test('Empty parametrized statement when limit is not defined', () => { - expect(limit(sample.statement)).toStrictEqual(null); + expect(limit(sample)).toStrictEqual(null); }); test('Returns limit part with one parameter', () => { - sample.statement.limit = { parameterIndex: 0 }; - sample.statement.parameters = [randomInteger(1, 100)]; - - expect(limit(sample.statement)).toStrictEqual(`LIMIT $1`); + sample.limit = { type: 'value', parameterIndex: 0 }; + expect(limit(sample)).toStrictEqual(`LIMIT $1`); }); diff --git a/packages/data-driver-postgres/src/query/limit.ts b/packages/data-driver-postgres/src/query/limit.ts index be40817870..4657055ac0 100644 --- a/packages/data-driver-postgres/src/query/limit.ts +++ b/packages/data-driver-postgres/src/query/limit.ts @@ -1,4 +1,4 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; /** * Generate the `LIMIT x` part of a SQL statement. @@ -6,7 +6,7 @@ import type { AbstractSqlQuery } from '@directus/data-sql'; * @param query The abstract query * @returns The `LIMIT x` part of a SQL statement */ -export function limit({ limit }: AbstractSqlQuery): string | null { +export function limit({ limit }: AbstractSqlClauses): string | null { if (limit === undefined) { return null; } diff --git a/packages/data-driver-postgres/src/query/offset.test.ts b/packages/data-driver-postgres/src/query/offset.test.ts index 2d16780de6..edbfe3e8c2 100644 --- a/packages/data-driver-postgres/src/query/offset.test.ts +++ b/packages/data-driver-postgres/src/query/offset.test.ts @@ -1,35 +1,22 @@ import { test, expect, beforeEach } from 'vitest'; import { offset } from './offset.js'; import { randomIdentifier } from '@directus/random'; -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; -let sample: { - statement: AbstractSqlQuery; -}; +let sample: AbstractSqlClauses; beforeEach(() => { sample = { - statement: { - select: [ - { - type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), - as: randomIdentifier(), - }, - { type: 'primitive', column: randomIdentifier(), table: randomIdentifier() }, - ], - from: randomIdentifier(), - parameters: [], - }, + select: [], + from: randomIdentifier(), }; }); test('Empty string when offset is not defined', () => { - expect(offset(sample.statement)).toStrictEqual(null); + expect(offset(sample)).toStrictEqual(null); }); test('Returns offset', () => { - sample.statement.offset = { parameterIndex: 0 }; - expect(offset(sample.statement)).toStrictEqual(`OFFSET $1`); + sample.offset = { type: 'value', parameterIndex: 0 }; + expect(offset(sample)).toStrictEqual(`OFFSET $1`); }); diff --git a/packages/data-driver-postgres/src/query/offset.ts b/packages/data-driver-postgres/src/query/offset.ts index 4816c07619..32e0bce606 100644 --- a/packages/data-driver-postgres/src/query/offset.ts +++ b/packages/data-driver-postgres/src/query/offset.ts @@ -1,4 +1,4 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; /** * Generate the `OFFSET x` part of a SQL statement. @@ -6,7 +6,7 @@ import type { AbstractSqlQuery } from '@directus/data-sql'; * @param query The abstract query * @returns The `OFFSET x` part of a SQL statement */ -export function offset({ offset }: AbstractSqlQuery): string | null { +export function offset({ offset }: AbstractSqlClauses): string | null { if (offset === undefined) { return null; } diff --git a/packages/data-driver-postgres/src/query/orderBy.test.ts b/packages/data-driver-postgres/src/query/orderBy.test.ts index 4627c16cb2..697d0c3693 100644 --- a/packages/data-driver-postgres/src/query/orderBy.test.ts +++ b/packages/data-driver-postgres/src/query/orderBy.test.ts @@ -1,58 +1,47 @@ import type { AbstractQueryFieldNodePrimitive } from '@directus/data'; -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { randomIdentifier } from '@directus/random'; import { beforeEach, expect, test } from 'vitest'; import { orderBy } from './orderBy.js'; -let sample: { - statement: AbstractSqlQuery; -}; +let sample: AbstractSqlClauses; beforeEach(() => { sample = { - statement: { - select: [ - { - type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), - as: randomIdentifier(), - }, - { type: 'primitive', column: randomIdentifier(), table: randomIdentifier() }, - ], - from: randomIdentifier(), - parameters: [], - }, + select: [], + from: randomIdentifier(), }; }); test('Empty parametrized statement when order is not defined', () => { - expect(orderBy(sample.statement)).toStrictEqual(null); + expect(orderBy(sample)).toStrictEqual(null); }); test('Returns order part for one primitive field', () => { - sample.statement.order = [ + sample.order = [ { orderBy: { type: 'primitive', field: randomIdentifier(), }, + type: 'order', direction: 'ASC', }, ]; - const expected = `ORDER BY "${(sample.statement.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field}" ASC`; + const expected = `ORDER BY "${(sample.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field}" ASC`; - expect(orderBy(sample.statement)).toStrictEqual(expected); + expect(orderBy(sample)).toStrictEqual(expected); }); test('Returns order part for multiple primitive fields', () => { - sample.statement.order = [ + sample.order = [ { orderBy: { type: 'primitive', field: randomIdentifier(), }, + type: 'order', direction: 'ASC', }, { @@ -60,13 +49,14 @@ test('Returns order part for multiple primitive fields', () => { type: 'primitive', field: randomIdentifier(), }, + type: 'order', direction: 'DESC', }, ]; - const expected = `ORDER BY "${(sample.statement.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field}" ASC, "${ - (sample.statement.order[1]!.orderBy as AbstractQueryFieldNodePrimitive).field + const expected = `ORDER BY "${(sample.order[0]!.orderBy as AbstractQueryFieldNodePrimitive).field}" ASC, "${ + (sample.order[1]!.orderBy as AbstractQueryFieldNodePrimitive).field }" DESC`; - expect(orderBy(sample.statement)).toStrictEqual(expected); + expect(orderBy(sample)).toStrictEqual(expected); }); diff --git a/packages/data-driver-postgres/src/query/orderBy.ts b/packages/data-driver-postgres/src/query/orderBy.ts index 4d9ef9af2f..6b4149578c 100644 --- a/packages/data-driver-postgres/src/query/orderBy.ts +++ b/packages/data-driver-postgres/src/query/orderBy.ts @@ -1,4 +1,4 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { escapeIdentifier } from '../utils/escape-identifier.js'; /** @@ -8,7 +8,7 @@ import { escapeIdentifier } from '../utils/escape-identifier.js'; * @param query - The abstract query * @returns The `ORDER BY x` part of a SQL statement */ -export function orderBy({ order }: AbstractSqlQuery): string | null { +export function orderBy({ order }: AbstractSqlClauses): string | null { if (order === undefined) { return null; } diff --git a/packages/data-driver-postgres/src/query/parameters.test.ts b/packages/data-driver-postgres/src/query/parameters.test.ts new file mode 100644 index 0000000000..87af765a51 --- /dev/null +++ b/packages/data-driver-postgres/src/query/parameters.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'vitest'; +import { convertGeoJsonParameterToWKT } from './parameters.js'; +import { randomAlpha, randomInteger } from '@directus/random'; +import type { GeoJSONGeometry } from 'wellknown'; + +test('Returns parameterized FROM with escaped identifier', () => { + const a = randomAlpha(25); + const b = randomInteger(0, 1000); + + const c = { + type: 'LineString', + coordinates: [ + [100.0, 0.0], + [101.0, 1.0], + ], + } as GeoJSONGeometry; + + const d = randomAlpha(45); + + const e = { + type: 'Polygon', + coordinates: [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ], + [ + [100.8, 0.8], + [100.8, 0.2], + [100.2, 0.2], + [100.2, 0.8], + [100.8, 0.8], + ], + ], + } as GeoJSONGeometry; + + expect(convertGeoJsonParameterToWKT([a, b, c, d, e])).toStrictEqual([ + a, + b, + 'LINESTRING (100 0, 101 1)', + d, + 'POLYGON ((100 0, 101 0, 101 1, 100 1, 100 0), (100.8 0.8, 100.8 0.2, 100.2 0.2, 100.2 0.8, 100.8 0.8))', + ]); +}); diff --git a/packages/data-driver-postgres/src/query/parameters.ts b/packages/data-driver-postgres/src/query/parameters.ts new file mode 100644 index 0000000000..a17a531d7a --- /dev/null +++ b/packages/data-driver-postgres/src/query/parameters.ts @@ -0,0 +1,55 @@ +/** + * Here the list of parameters created in data-sql are converted here. + * Currently this only includes the conversion of GeoJSON objects to WKT. + * @module + */ +import { stringify, type GeoJSONGeometry } from 'wellknown'; +import type { ParameterTypes } from '@directus/data-sql'; + +export function convertParameters(params: ParameterTypes[]) { + return convertGeoJsonParameterToWKT(params); +} + +/** + * Goes through the list of parameters and converts all GeoJson objects to a WKT representation. + * @param params - the list of parameters + * @returns - a list where all GeoJson objects were converted to a string + */ +export function convertGeoJsonParameterToWKT(params: ParameterTypes[]): ParameterTypes[] { + return params.map((p) => { + if (isGeoJson(p)) { + return stringify(p as GeoJSONGeometry); + } + + return p; + }); +} + +/** + * Checks if a given parameter is a GeoJSON object or not. + * + * @see https://datatracker.ietf.org/doc/html/rfc7946#section-1.4 + * @param parameter + * @returns true if the parameter is a GeoJSON object + */ +function isGeoJson(parameter: ParameterTypes): boolean { + if (typeof parameter === 'object') { + const props = Object.keys(parameter); + + if (props.includes('type')) { + [ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', + 'GeometryCollection', + ].includes(parameter.type); + + return true; + } + } + + return false; +} diff --git a/packages/data-driver-postgres/src/query/select.test.ts b/packages/data-driver-postgres/src/query/select.test.ts index a5053e84a9..896ed23462 100644 --- a/packages/data-driver-postgres/src/query/select.test.ts +++ b/packages/data-driver-postgres/src/query/select.test.ts @@ -1,36 +1,61 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { beforeEach, expect, test } from 'vitest'; import { select } from './select.js'; import { randomIdentifier } from '@directus/random'; -let sample: { - statement: AbstractSqlQuery; -}; +let randomTable: string; beforeEach(() => { - sample = { - statement: { - select: [ - { - type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), - as: randomIdentifier(), - }, - { type: 'primitive', column: randomIdentifier(), table: randomIdentifier() }, - ], - from: randomIdentifier(), - parameters: [], - }, - }; + randomTable = randomIdentifier(); }); test('With multiple provided fields and an alias', () => { - const res = select(sample.statement); + const randomTable2 = randomIdentifier(); + const randomColumn1 = randomIdentifier(); + const randomColumn2 = randomIdentifier(); + const randomAlias = randomIdentifier(); - const expected = `SELECT "${sample.statement.select[0]!.table}"."${sample.statement.select[0]!.column}" AS "${ - sample.statement.select[0]!.as - }", "${sample.statement.select[1]!.table}"."${sample.statement.select[1]!.column}"`; + const sample: AbstractSqlClauses = { + select: [ + { + type: 'primitive', + table: randomTable, + column: randomColumn1, + as: randomAlias, + }, + { + type: 'primitive', + table: randomTable2, + column: randomColumn2, + }, + ], + from: randomTable, + }; + const res = select(sample); + const expected = `SELECT "${randomTable}"."${randomColumn1}" AS "${randomAlias}", "${randomTable2}"."${randomColumn2}"`; + expect(res).toStrictEqual(expected); +}); + +test('With a count', () => { + const randomTable = randomIdentifier(); + + const sample: AbstractSqlClauses = { + select: [ + { + type: 'fn', + fn: { + type: 'arrayFn', + fn: 'count', + }, + table: randomTable, + column: '*', + }, + ], + from: randomTable, + }; + + const res = select(sample); + const expected = `SELECT COUNT("${randomTable}"."*")`; expect(res).toStrictEqual(expected); }); diff --git a/packages/data-driver-postgres/src/query/select.ts b/packages/data-driver-postgres/src/query/select.ts index b1d2780a94..0aa8d3dc9d 100644 --- a/packages/data-driver-postgres/src/query/select.ts +++ b/packages/data-driver-postgres/src/query/select.ts @@ -1,11 +1,26 @@ -import type { AbstractSqlQuery } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { wrapColumn } from '../utils/wrap-column.js'; +import { applyFunction } from '../utils/functions.js'; /** * Generates the `SELECT x, y` part of a SQL statement. * The fields are always prefixed with the table name. + * + * @param AbstractSqlQuery the whole query object + * @returns the `SELECT x, y` part of a SQL statement */ -export const select = ({ select }: AbstractSqlQuery): string => { - const escapedColumns = select.map(({ table, column, as }) => wrapColumn(table, column, as)); +export const select = ({ select }: AbstractSqlClauses): string => { + const escapedColumns = select.map((selectNode) => { + if (selectNode.type === 'primitive') { + return wrapColumn(selectNode.table, selectNode.column, selectNode.as); + } + + if (selectNode.type === 'fn') { + return applyFunction(selectNode); + } + + throw Error(`Unknown node type`); + }); + return `SELECT ${escapedColumns.join(', ')}`; }; diff --git a/packages/data-driver-postgres/src/query/where.test.ts b/packages/data-driver-postgres/src/query/where.test.ts index 9e74e45868..78b78609a4 100644 --- a/packages/data-driver-postgres/src/query/where.test.ts +++ b/packages/data-driver-postgres/src/query/where.test.ts @@ -1,281 +1,47 @@ -import type { AbstractSqlQuery, AbstractSqlQueryWhereConditionNode, CompareValueNode } from '@directus/data-sql'; +import type { AbstractSqlClauses } from '@directus/data-sql'; import { beforeEach, describe, expect, test } from 'vitest'; -import { where, getComparison } from './where.js'; -import { randomIdentifier, randomInteger } from '@directus/random'; +import { where } from './where.js'; +import { randomIdentifier } from '@directus/random'; -let sample: { - statement: AbstractSqlQuery; -}; +let sample: AbstractSqlClauses; +let conditionTargetTable: string; +let conditionTargetColumn: string; describe('Where clause:', () => { beforeEach(() => { + conditionTargetTable = randomIdentifier(); + conditionTargetColumn = randomIdentifier(); + sample = { - statement: { - select: [ - { - type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), - as: randomIdentifier(), - }, - ], - from: randomIdentifier(), - where: { - type: 'condition', + select: [], + from: randomIdentifier(), + where: { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', operation: 'gt', - negate: false, target: { type: 'primitive', - column: randomIdentifier(), - table: randomIdentifier(), + table: conditionTargetTable, + column: conditionTargetColumn, }, compareTo: { type: 'value', - parameterIndexes: [0], + parameterIndex: 0, }, }, - parameters: [randomInteger(1, 10)], }, }; }); test('Where clause', () => { - expect(where(sample.statement)).toStrictEqual( - `WHERE "${(sample.statement.where as AbstractSqlQueryWhereConditionNode).target.table}"."${ - (sample.statement.where as AbstractSqlQueryWhereConditionNode).target.column - }" > $${ - ((sample.statement.where as AbstractSqlQueryWhereConditionNode).compareTo as CompareValueNode) - .parameterIndexes[0]! + 1 - }` - ); + expect(where(sample)).toStrictEqual(`WHERE "${conditionTargetTable}"."${conditionTargetColumn}" > $1`); }); test('Where clause with negation', () => { - sample.statement.where!.negate = true; + sample.where!.negate = true; - expect(where(sample.statement)).toStrictEqual( - `WHERE "${(sample.statement.where as AbstractSqlQueryWhereConditionNode).target.table}"."${ - (sample.statement.where as AbstractSqlQueryWhereConditionNode).target.column - }" <= $${ - ((sample.statement.where as AbstractSqlQueryWhereConditionNode).compareTo as CompareValueNode) - .parameterIndexes[0]! + 1 - }` - ); + expect(where(sample)).toStrictEqual(`WHERE "${conditionTargetTable}"."${conditionTargetColumn}" <= $1`); }); }); - -describe('Where clause operator mapping and parameter index insertion: ', () => { - let compareTo: CompareValueNode; - - beforeEach(() => { - compareTo = { - type: 'value', - parameterIndexes: [randomInteger(1, 10)], - }; - }); - - test('eq', () => { - expect(getComparison('eq', compareTo)).toStrictEqual(`= $${compareTo.parameterIndexes[0]! + 1}`); - }); - - test('gt', () => { - expect(getComparison('gt', compareTo)).toStrictEqual(`> $${compareTo.parameterIndexes[0]! + 1}`); - }); - - test('gte', () => { - expect(getComparison('gte', compareTo)).toStrictEqual(`>= $${compareTo.parameterIndexes[0]! + 1}`); - }); - - test('lt', () => { - expect(getComparison('lt', compareTo)).toStrictEqual(`< $${compareTo.parameterIndexes[0]! + 1}`); - }); - - test('lte', () => { - expect(getComparison('lte', compareTo)).toStrictEqual(`<= $${compareTo.parameterIndexes[0]! + 1}`); - }); - - test('contains', () => { - expect(getComparison('contains', compareTo)).toStrictEqual(`LIKE '%$${compareTo.parameterIndexes[0]! + 1}%'`); - }); - - test('starts_with', () => { - expect(getComparison('starts_with', compareTo)).toStrictEqual(`LIKE '$${compareTo.parameterIndexes[0]! + 1}%'`); - }); - - test('ends_with', () => { - expect(getComparison('ends_with', compareTo)).toStrictEqual(`LIKE '%$${compareTo.parameterIndexes[0]! + 1}'`); - }); - - test('in', () => { - compareTo = { - type: 'value', - parameterIndexes: [randomInteger(1, 10), randomInteger(1, 10)], - }; - - expect(getComparison('in', compareTo)).toStrictEqual( - `IN ($${compareTo.parameterIndexes[0]! + 1}, $${compareTo.parameterIndexes[1]! + 1})` - ); - }); -}); - -test('Convert filter with logical', () => { - const randomTable = randomIdentifier(); - const randomColumn = randomIdentifier(); - - const firstColumn = randomIdentifier(); - const secondColumn = randomIdentifier(); - const firstValue = randomInteger(1, 100); - const secondValue = randomInteger(1, 100); - - const statement: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: randomTable, - column: randomColumn, - }, - ], - from: randomTable, - where: { - type: 'logical', - operator: 'or', - negate: false, - childNodes: [ - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomTable, - column: firstColumn, - }, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }, - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomTable, - column: secondColumn, - }, - operation: 'eq', - compareTo: { - type: 'value', - parameterIndexes: [1], - }, - }, - ], - }, - parameters: [firstValue, secondValue], - }; - - expect(where(statement)).toStrictEqual( - `WHERE "${randomTable}"."${firstColumn}" > $1 OR "${randomTable}"."${secondColumn}" = $2` - ); -}); - -test('Convert filter nested and with negation', () => { - const randomTable = randomIdentifier(); - const randomColumn = randomIdentifier(); - - const firstColumn = randomIdentifier(); - const secondColumn = randomIdentifier(); - const thirdColumn = randomIdentifier(); - const fourthColumn = randomIdentifier(); - - const firstValue = randomInteger(1, 100); - const secondValue = randomInteger(1, 100); - const thirdValue = randomInteger(1, 100); - const fourthValue = randomInteger(1, 100); - - const statement: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: randomTable, - column: randomColumn, - }, - ], - from: randomTable, - where: { - type: 'logical', - operator: 'or', - negate: false, - childNodes: [ - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomTable, - column: firstColumn, - }, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }, - { - type: 'condition', - negate: true, - target: { - type: 'primitive', - table: randomTable, - column: secondColumn, - }, - operation: 'eq', - compareTo: { - type: 'value', - parameterIndexes: [1], - }, - }, - { - type: 'logical', - operator: 'and', - negate: true, - childNodes: [ - { - type: 'condition', - negate: true, - target: { - type: 'primitive', - table: randomTable, - column: thirdColumn, - }, - operation: 'lt', - compareTo: { - type: 'value', - parameterIndexes: [2], - }, - }, - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomTable, - column: fourthColumn, - }, - operation: 'eq', - compareTo: { - type: 'value', - parameterIndexes: [3], - }, - }, - ], - }, - ], - }, - parameters: [firstValue, secondValue, thirdValue, fourthValue], - }; - - expect(where(statement)).toStrictEqual( - `WHERE "${randomTable}"."${firstColumn}" > $1 OR "${randomTable}"."${secondColumn}" != $2 OR NOT ("${randomTable}"."${thirdColumn}" >= $3 AND "${randomTable}"."${fourthColumn}" = $4)` - ); -}); diff --git a/packages/data-driver-postgres/src/query/where.ts b/packages/data-driver-postgres/src/query/where.ts index 033b5f69d2..e45ade2e61 100644 --- a/packages/data-driver-postgres/src/query/where.ts +++ b/packages/data-driver-postgres/src/query/where.ts @@ -1,14 +1,8 @@ /** - * @todo - * Move this module outside of src/query because it's not only used for query, but also for modifications like update and delete. + * This module should be moved outside of src/query because it's not only used for querying, but also for UPDATE or DELETE commands. */ -import type { - AbstractSqlQuery, - AbstractSqlQueryWhereConditionNode, - AbstractSqlQueryWhereLogicalNode, - CompareValueNode, -} from '@directus/data-sql'; -import { wrapColumn } from '../utils/wrap-column.js'; +import type { AbstractSqlClauses } from '@directus/data-sql'; +import { conditionString } from '../utils/conditions/index.js'; /** * Creates the WHERE clause for a SQL query. @@ -16,63 +10,10 @@ import { wrapColumn } from '../utils/wrap-column.js'; * @param - The abstract SQL query. * @returns The WHERE clause or null if no WHERE clause is needed. */ -export const where = ({ where }: AbstractSqlQuery): string | null => { +export const where = ({ where }: AbstractSqlClauses): string | null => { if (where === undefined) { return null; } - return `WHERE ${whereString(where)}`; + return `WHERE ${conditionString(where)}`; }; - -const whereString = (where: AbstractSqlQueryWhereConditionNode | AbstractSqlQueryWhereLogicalNode): string => { - if (where.type === 'condition') { - const target = wrapColumn(where.target.table, where.target.column); - - const comparison = getComparison(where.operation, where.compareTo, where.negate); - - return `${target} ${comparison}`; - } else { - const logicalGroup = where.childNodes - .map((childNode) => - childNode.type === 'condition' || childNode.negate ? whereString(childNode) : `(${whereString(childNode)})` - ) - .join(where.operator === 'and' ? ' AND ' : ' OR '); - - return where.negate ? `NOT (${logicalGroup})` : logicalGroup; - } -}; - -/** - * Converts the abstract operators to SQL operators and adds the value to which should be compared. - * Depending on how the other SQL drivers look like regarding this, this function may be moved to @directus/data-sql. - * - * @param operation - The abstract operator. - * @param providedIndexes - The indexes of all parameters. - * @returns An operator with a parameter reference to a value to which the target will be compared. - */ -export function getComparison(operation: string, compareTo: CompareValueNode, negate = false) { - const parameterIndex = compareTo.parameterIndexes[0]! + 1; - - switch (operation) { - case 'eq': - return `${negate ? '!=' : '='} $${parameterIndex}`; - case 'gt': - return `${negate ? '<=' : '>'} $${parameterIndex}`; - case 'gte': - return `${negate ? '<' : '>='} $${parameterIndex}`; - case 'lt': - return `${negate ? '>=' : '<'} $${parameterIndex}`; - case 'lte': - return `${negate ? '>' : '<='} $${parameterIndex}`; - case 'contains': - return `${negate ? 'NOT LIKE' : 'LIKE'} '%$${parameterIndex}%'`; - case 'starts_with': - return `${negate ? 'NOT LIKE' : 'LIKE'} '$${parameterIndex}%'`; - case 'ends_with': - return `${negate ? 'NOT LIKE' : 'LIKE'} '%$${parameterIndex}'`; - case 'in': - return `${negate ? 'NOT IN' : 'IN'} (${compareTo.parameterIndexes.map((i) => `$${i + 1}`).join(', ')})`; - default: - throw new Error(`Unsupported operation: ${operation}`); - } -} diff --git a/packages/data-driver-postgres/src/utils/conditions/field-condition.test.ts b/packages/data-driver-postgres/src/utils/conditions/field-condition.test.ts new file mode 100644 index 0000000000..0b70789a9b --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/field-condition.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest'; +import { fieldCondition } from './field-condition.js'; +import { randomIdentifier } from '@directus/random'; + +test('field condition', () => { + const table1 = randomIdentifier(); + const table2 = randomIdentifier(); + const column1 = randomIdentifier(); + const column2 = randomIdentifier(); + + const res = fieldCondition( + { + type: 'condition-field', + target: { + type: 'primitive', + table: table1, + column: column1, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: table2, + column: column2, + }, + }, + false + ); + + expect(res).toStrictEqual(`"${table1}"."${column1}" = "${table2}"."${column2}"`); +}); diff --git a/packages/data-driver-postgres/src/utils/conditions/field-condition.ts b/packages/data-driver-postgres/src/utils/conditions/field-condition.ts new file mode 100644 index 0000000000..38d382677a --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/field-condition.ts @@ -0,0 +1,15 @@ +import { convertNumericOperators, type SqlConditionFieldNode } from '@directus/data-sql'; +import { wrapColumn } from '../wrap-column.js'; + +/** + * This is mainly used for JOIN conditions. + * @param condition + * @param negate + * @returns col1 = col2 + */ +export const fieldCondition = (condition: SqlConditionFieldNode, negate: boolean): string => { + const column1 = wrapColumn(condition.target.table, condition.target.column); + const column2 = wrapColumn(condition.compareTo.table, condition.compareTo.column); + const operation = convertNumericOperators(condition.operation, negate); + return `${column1} ${operation} ${column2}`; +}; diff --git a/packages/data-driver-postgres/src/utils/conditions/geo-condition.test.ts b/packages/data-driver-postgres/src/utils/conditions/geo-condition.test.ts new file mode 100644 index 0000000000..1c82945aa0 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/geo-condition.test.ts @@ -0,0 +1,43 @@ +import { expect, test, beforeEach } from 'vitest'; +import { randomIdentifier, randomInteger } from '@directus/random'; +import { geoCondition } from './geo-condition.js'; +import type { SqlConditionGeoNode } from '@directus/data-sql'; + +let sampleCondition: SqlConditionGeoNode; +let randomTable: string; +let randomColumn: string; +let parameterIndex: number; + +beforeEach(() => { + randomTable = randomIdentifier(); + randomColumn = randomIdentifier(); + parameterIndex = randomInteger(0, 100); + + sampleCondition = { + type: 'condition-geo', + target: { + type: 'primitive', + table: randomTable, + column: randomColumn, + }, + operation: 'intersects', + compareTo: { + type: 'value', + parameterIndex, + }, + }; +}); + +test('intersects', () => { + expect(geoCondition(sampleCondition)).toStrictEqual( + `ST_Intersects("${randomTable}"."${randomColumn}", ST_GeomFromText($${parameterIndex + 1}))` + ); +}); + +test('intersects_bbox', () => { + sampleCondition.operation = 'intersects_bbox'; + + expect(geoCondition(sampleCondition)).toStrictEqual( + `"${randomTable}"."${randomColumn}" && ST_GeomFromText($${parameterIndex + 1}))` + ); +}); diff --git a/packages/data-driver-postgres/src/utils/conditions/geo-condition.ts b/packages/data-driver-postgres/src/utils/conditions/geo-condition.ts new file mode 100644 index 0000000000..75f6591f4b --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/geo-condition.ts @@ -0,0 +1,28 @@ +import type { SqlConditionGeoNode } from '@directus/data-sql'; +import { wrapColumn } from '../wrap-column.js'; + +/** + * Used to check if a geo shape intersects with another geo shape. + * @see http://www.postgis.net/docs/ST_Intersects.html + * @see https://postgis.net/docs/geometry_overlaps.html + * + * @remarks + * The arguments to the PostGis intersect functions need to be in specific 'geometry' object. + * Therefore the provided geo json, which is stored in the parameter list, needs to be converted using another PostGis function. + * @see https://postgis.net/docs/ST_GeomFromText.html + * + * @param condition + * @returns + */ +export const geoCondition = (condition: SqlConditionGeoNode): string => { + const column = wrapColumn(condition.target.table, condition.target.column); + const parameterIndex = condition.compareTo.parameterIndex; + const geomConvertedText = `ST_GeomFromText($${parameterIndex + 1})`; + + switch (condition.operation) { + case 'intersects': + return `ST_Intersects(${column}, ${geomConvertedText})`; + case 'intersects_bbox': + return `${column} && ${geomConvertedText})`; + } +}; diff --git a/packages/data-driver-postgres/src/utils/conditions/index.test.ts b/packages/data-driver-postgres/src/utils/conditions/index.test.ts new file mode 100644 index 0000000000..e3e647df00 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/index.test.ts @@ -0,0 +1,153 @@ +import type { AbstractSqlQueryLogicalNode } from '@directus/data-sql'; +import { expect, test } from 'vitest'; +import { randomIdentifier } from '@directus/random'; +import { conditionString } from './index.js'; + +test('Convert filter with logical', () => { + const randomTable = randomIdentifier(); + + const firstColumn = randomIdentifier(); + const secondColumn = randomIdentifier(); + + const where: AbstractSqlQueryLogicalNode = { + type: 'logical', + operator: 'or', + negate: false, + childNodes: [ + { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: firstColumn, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }, + { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: secondColumn, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 1, + }, + }, + }, + ], + }; + + expect(conditionString(where)).toStrictEqual( + `"${randomTable}"."${firstColumn}" > $1 OR "${randomTable}"."${secondColumn}" = $2` + ); +}); + +test('Convert filter nested and with negation', () => { + const randomTable = randomIdentifier(); + + const firstColumn = randomIdentifier(); + const secondColumn = randomIdentifier(); + const thirdColumn = randomIdentifier(); + const fourthColumn = randomIdentifier(); + + const where: AbstractSqlQueryLogicalNode = { + type: 'logical', + operator: 'or', + negate: false, + childNodes: [ + { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: firstColumn, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }, + { + type: 'condition', + negate: true, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: secondColumn, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 1, + }, + }, + }, + { + type: 'logical', + operator: 'and', + negate: true, + childNodes: [ + { + type: 'condition', + negate: true, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: thirdColumn, + }, + operation: 'lt', + compareTo: { + type: 'value', + parameterIndex: 2, + }, + }, + }, + { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: fourthColumn, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 3, + }, + }, + }, + ], + }, + ], + }; + + expect(conditionString(where)).toStrictEqual( + `"${randomTable}"."${firstColumn}" > $1 OR "${randomTable}"."${secondColumn}" != $2 OR NOT ("${randomTable}"."${thirdColumn}" >= $3 AND "${randomTable}"."${fourthColumn}" = $4)` + ); +}); diff --git a/packages/data-driver-postgres/src/utils/conditions/index.ts b/packages/data-driver-postgres/src/utils/conditions/index.ts new file mode 100644 index 0000000000..362c1de41a --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/index.ts @@ -0,0 +1,59 @@ +import type { AbstractSqlQueryConditionNode, AbstractSqlQueryLogicalNode } from '@directus/data-sql'; +import { numberCondition } from './number-condition.js'; +import { stringCondition } from './string-condition.js'; +import { geoCondition } from './geo-condition.js'; +import { setCondition } from './set-condition.js'; +import { fieldCondition } from './field-condition.js'; + +/** + * Create a condition string with optional negation or nesting. + * It's used by the where and the join clause. + * @param node the condition node or logical wrapper node + * @returns the whole condition string included nested conditions + */ +export const conditionString = (node: AbstractSqlQueryConditionNode | AbstractSqlQueryLogicalNode): string => { + if (node.type === 'logical') { + return applyLogicalCondition(node); + } + + return getCondition(node); +}; + +/** + * Gets a single condition without logical operators. + * @param conditionNode + * @returns a single condition + */ +function getCondition(conditionNode: AbstractSqlQueryConditionNode) { + switch (conditionNode.condition.type) { + case 'condition-number': + return numberCondition(conditionNode.condition, conditionNode.negate); + case 'condition-string': + return stringCondition(conditionNode.condition, conditionNode.negate); + case 'condition-geo': + return geoCondition(conditionNode.condition); + case 'condition-set': + return setCondition(conditionNode.condition, conditionNode.negate); + case 'condition-field': + return fieldCondition(conditionNode.condition, conditionNode.negate); + } +} + +/** + * Creates a condition with a logical operator like AND or OR, as well as the negation. + * @param logicalNode a logical wrapper which holds the logical operator and the conditions + * @returns a nested condition + */ +function applyLogicalCondition(logicalNode: AbstractSqlQueryLogicalNode) { + const logicalGroup = logicalNode.childNodes + .map((childNode) => { + if (childNode.type === 'condition' || childNode.negate) { + return conditionString(childNode); + } + + return `(${conditionString(childNode)})`; + }) + .join(logicalNode.operator === 'and' ? ' AND ' : ' OR '); + + return logicalNode.negate ? `NOT (${logicalGroup})` : logicalGroup; +} diff --git a/packages/data-driver-postgres/src/utils/conditions/number-condition.test.ts b/packages/data-driver-postgres/src/utils/conditions/number-condition.test.ts new file mode 100644 index 0000000000..701b249ca4 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/number-condition.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, test } from 'vitest'; +import { randomIdentifier, randomInteger } from '@directus/random'; +import { numberCondition } from './number-condition.js'; +import type { SqlConditionNumberNode } from '@directus/data-sql'; + +let randomTable: string; +let aColumn: string; +let parameterIndex: number; +let sampleCondition: SqlConditionNumberNode; + +beforeEach(() => { + randomTable = randomIdentifier(); + aColumn = randomIdentifier(); + parameterIndex = randomInteger(0, 100); + + sampleCondition = { + type: 'condition-number', + target: { + type: 'primitive', + table: randomTable, + column: aColumn, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex, + }, + }; +}); + +test('number condition', () => { + expect(numberCondition(sampleCondition, false)).toStrictEqual( + `"${randomTable}"."${aColumn}" > $${parameterIndex + 1}` + ); +}); + +test('number condition with function', () => { + sampleCondition.target = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + table: randomTable, + column: aColumn, + }; + + expect(numberCondition(sampleCondition, false)).toStrictEqual( + `EXTRACT(MONTH FROM "${randomTable}"."${aColumn}") > $${parameterIndex + 1}` + ); +}); diff --git a/packages/data-driver-postgres/src/utils/conditions/number-condition.ts b/packages/data-driver-postgres/src/utils/conditions/number-condition.ts new file mode 100644 index 0000000000..5c92943334 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/number-condition.ts @@ -0,0 +1,19 @@ +import { convertNumericOperators, type SqlConditionNumberNode } from '@directus/data-sql'; +import { applyFunction } from '../functions.js'; +import { wrapColumn } from '../wrap-column.js'; + +export const numberCondition = (conditionNode: SqlConditionNumberNode, negate: boolean): string => { + const target = conditionNode.target; + let firstOperand; + + if (target.type === 'fn') { + firstOperand = applyFunction(target); + } else { + firstOperand = wrapColumn(target.table, target.column); + } + + const compareValue = `$${conditionNode.compareTo.parameterIndex + 1}`; + const operation = convertNumericOperators(conditionNode.operation, negate); + + return `${firstOperand} ${operation} ${compareValue}`; +}; diff --git a/packages/data-driver-postgres/src/utils/conditions/set-condition.test.ts b/packages/data-driver-postgres/src/utils/conditions/set-condition.test.ts new file mode 100644 index 0000000000..61881e13af --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/set-condition.test.ts @@ -0,0 +1,39 @@ +import { expect, test, beforeEach } from 'vitest'; +import { randomIdentifier } from '@directus/random'; +import { setCondition } from './set-condition.js'; +import type { SqlConditionSetNode } from '@directus/data-sql'; + +let randomTable: string; +let randomColumn: string; +let sampleCondition: SqlConditionSetNode; + +beforeEach(() => { + randomTable = randomIdentifier(); + randomColumn = randomIdentifier(); + + sampleCondition = { + type: 'condition-set', + target: { + type: 'primitive', + table: randomTable, + column: randomColumn, + }, + operation: 'in', + compareTo: { + type: 'values', + parameterIndexes: [2, 3, 4], + }, + }; +}); + +test('set', () => { + const res = setCondition(sampleCondition, false); + const expected = `"${randomTable}"."${randomColumn}" IN ($3, $4, $5)`; + expect(res).toStrictEqual(expected); +}); + +test('negated set', () => { + const res = setCondition(sampleCondition, true); + const expected = `"${randomTable}"."${randomColumn}" NOT IN ($3, $4, $5)`; + expect(res).toStrictEqual(expected); +}); diff --git a/packages/data-driver-postgres/src/utils/conditions/set-condition.ts b/packages/data-driver-postgres/src/utils/conditions/set-condition.ts new file mode 100644 index 0000000000..d24a601d27 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/set-condition.ts @@ -0,0 +1,13 @@ +import type { SqlConditionSetNode } from '@directus/data-sql'; +import { wrapColumn } from '../wrap-column.js'; + +export const setCondition = (condition: SqlConditionSetNode, negate: boolean): string => { + const column = wrapColumn(condition.target.table, condition.target.column); + const compareValues = condition.compareTo.parameterIndexes.map((i) => `$${i + 1}`).join(', '); + + if (negate) { + return `${column} NOT IN (${compareValues})`; + } + + return `${column} IN (${compareValues})`; +}; diff --git a/packages/data-driver-postgres/src/utils/conditions/string-condition.test.ts b/packages/data-driver-postgres/src/utils/conditions/string-condition.test.ts new file mode 100644 index 0000000000..401fe455f5 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/string-condition.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, expect, test } from 'vitest'; +import { randomIdentifier, randomInteger } from '@directus/random'; +import { stringCondition } from './string-condition.js'; +import type { SqlConditionStringNode } from '@directus/data-sql'; + +let sampleCondition: SqlConditionStringNode; +let randomTable: string; +let randomColumn: string; +let parameterIndex: number; + +beforeEach(() => { + randomTable = randomIdentifier(); + randomColumn = randomIdentifier(); + parameterIndex = randomInteger(0, 100); + + sampleCondition = { + type: 'condition-string', + target: { + type: 'primitive', + table: randomTable, + column: randomColumn, + }, + operation: 'starts_with', + compareTo: { + type: 'value', + parameterIndex, + }, + }; +}); + +test('letter condition starts_with', () => { + const res = stringCondition(sampleCondition, false); + const expected = `"${randomTable}"."${randomColumn}" LIKE '$${parameterIndex + 1}%'`; + expect(res).toStrictEqual(expected); +}); + +test('letter condition contains', () => { + sampleCondition.operation = 'contains'; + const res = stringCondition(sampleCondition, false); + const expected = `"${randomTable}"."${randomColumn}" LIKE '%$${parameterIndex + 1}%'`; + expect(res).toStrictEqual(expected); +}); + +test('letter condition contains', () => { + sampleCondition.operation = 'ends_with'; + const res = stringCondition(sampleCondition, false); + const expected = `"${randomTable}"."${randomColumn}" LIKE '%$${parameterIndex + 1}'`; + expect(res).toStrictEqual(expected); +}); diff --git a/packages/data-driver-postgres/src/utils/conditions/string-condition.ts b/packages/data-driver-postgres/src/utils/conditions/string-condition.ts new file mode 100644 index 0000000000..de4d7eb5c4 --- /dev/null +++ b/packages/data-driver-postgres/src/utils/conditions/string-condition.ts @@ -0,0 +1,27 @@ +import type { SqlConditionStringNode } from '@directus/data-sql'; +import { wrapColumn } from '../wrap-column.js'; + +export const stringCondition = (condition: SqlConditionStringNode, negate: boolean): string => { + const column = wrapColumn(condition.target.table, condition.target.column); + const compareValue = `$${condition.compareTo.parameterIndex + 1}`; + + if (condition.operation === 'eq') { + return `${column} ${negate ? '!=' : '='} ${compareValue}`; + } + + let likeValue = ''; + + switch (condition.operation) { + case 'contains': + likeValue = `'%${compareValue}%'`; + break; + case 'starts_with': + likeValue = `'${compareValue}%'`; + break; + case 'ends_with': + likeValue = `'%${compareValue}'`; + break; + } + + return `${column} ${negate ? 'NOT LIKE' : 'LIKE'} ${likeValue}`; +}; diff --git a/packages/data-driver-postgres/src/utils/functions.test.ts b/packages/data-driver-postgres/src/utils/functions.test.ts new file mode 100644 index 0000000000..b4cebc8f7f --- /dev/null +++ b/packages/data-driver-postgres/src/utils/functions.test.ts @@ -0,0 +1,56 @@ +import { expect, test, describe, beforeEach } from 'vitest'; +import { applyFunction } from './functions.js'; +import type { AbstractSqlQueryFnNode } from '@directus/data-sql'; +import { randomIdentifier } from '@directus/random'; + +let randomTable: string; +let randomColumn: string; +let sample: AbstractSqlQueryFnNode; + +beforeEach(() => { + randomTable = randomIdentifier(); + randomColumn = randomIdentifier(); + + sample = { + type: 'fn', + table: randomTable, + column: randomColumn, + fn: { + type: 'extractFn', + fn: 'year', + isTimestampType: false, + }, + }; +}); + +describe('Apply date time function', () => { + test('On timestamp column', () => { + const res = applyFunction(sample); + const expected = `EXTRACT(YEAR FROM "${randomTable}"."${randomColumn}")`; + expect(res).toStrictEqual(expected); + }); + + test('On non timestamp column', () => { + // @ts-ignore + sample.fn.isTimestampType = true; + const res = applyFunction(sample); + const expected = `EXTRACT(YEAR FROM "${randomTable}"."${randomColumn}" AT TIME ZONE 'UTC')`; + expect(res).toStrictEqual(expected); + }); +}); + +test('Apply count', () => { + sample = { + type: 'fn', + fn: { + type: 'arrayFn', + fn: 'count', + }, + table: randomTable, + column: '*', + }; + + const res = applyFunction(sample); + const expected = `COUNT("${randomTable}"."*")`; + expect(res).toStrictEqual(expected); +}); diff --git a/packages/data-driver-postgres/src/utils/functions.ts b/packages/data-driver-postgres/src/utils/functions.ts new file mode 100644 index 0000000000..003c0e1a4b --- /dev/null +++ b/packages/data-driver-postgres/src/utils/functions.ts @@ -0,0 +1,58 @@ +import type { AbstractSqlQueryFnNode } from '@directus/data-sql'; +import { wrapColumn } from './wrap-column.js'; +import type { ExtractFn } from '@directus/data'; + +/** + * Wraps a column with a function. + * + * @param fnNode - The function node which holds the function name and the column + * @returns Basically FN("table"."column") + */ +export function applyFunction(fnNode: AbstractSqlQueryFnNode) { + const wrappedColumn = wrapColumn(fnNode.table, fnNode.column); + + if (fnNode.fn.type === 'arrayFn') { + // count is the only array function we currently support + return `COUNT(${wrappedColumn})`; + } + + return applyDateTimeFn(fnNode, wrappedColumn); +} + +/** + * Applies a function to a column. + * The EXTRACT functions which is being used for this needs two parameters: + * - the field to extract from + * - the source - which can be TIMESTAMP or INTERVAL. Here we only use/support TIMESTAMP. + * The result of any of our supported functions is a number. + * + * @param fnNode - Specifies the function to use and the type of the target column + * @param column - The column which will be used as the argument for the function + * @returns - F.e. EXTRACT(YEAR FROM "table"."column") + */ +export const applyDateTimeFn = (fnNode: AbstractSqlQueryFnNode, col: string): string => { + switch (fnNode.fn.fn) { + case 'year': + return applyFn('YEAR', fnNode.fn); + case 'month': + return applyFn('MONTH', fnNode.fn); + case 'week': + return applyFn('WEEK', fnNode.fn); + case 'day': + return applyFn('DAY', fnNode.fn); + case 'weekday': + return applyFn('DOW', fnNode.fn); + case 'hour': + return applyFn('HOUR', fnNode.fn); + case 'minute': + return applyFn('MINUTE', fnNode.fn); + case 'second': + return applyFn('SECOND', fnNode.fn); + default: + throw new Error(`Function ${fnNode} is not supported.`); + } + + function applyFn(functionName: string, fn: ExtractFn) { + return `EXTRACT(${functionName} FROM ${col}${fn.isTimestampType ? " AT TIME ZONE 'UTC'" : ''})`; + } +}; diff --git a/packages/data-driver-postgres/src/utils/index.ts b/packages/data-driver-postgres/src/utils/index.ts deleted file mode 100644 index c5655db0e6..0000000000 --- a/packages/data-driver-postgres/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { escapeIdentifier } from './escape-identifier.js'; diff --git a/packages/data-driver-postgres/src/utils/wrap-column.ts b/packages/data-driver-postgres/src/utils/wrap-column.ts index 65f8a038b9..377a942a9d 100644 --- a/packages/data-driver-postgres/src/utils/wrap-column.ts +++ b/packages/data-driver-postgres/src/utils/wrap-column.ts @@ -1,7 +1,8 @@ import { escapeIdentifier } from './escape-identifier.js'; /** - * Gets a primitive field value. + * Adds the table name to the column and escapes the identifiers. + * * @param table * @param column * @param as diff --git a/packages/data-sql/.dependency-cruiser.cjs b/packages/data-sql/.dependency-cruiser.cjs new file mode 100644 index 0000000000..d925ae79d3 --- /dev/null +++ b/packages/data-sql/.dependency-cruiser.cjs @@ -0,0 +1,435 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: 'no-circular', + severity: 'warn', + comment: + 'This dependency is part of a circular relationship. You might want to revise ' + + 'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', + from: {}, + to: { + circular: true, + }, + }, + { + name: 'no-orphans', + comment: + "This is an orphan module - it's likely not used (anymore?). Either use it or " + + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " + + 'add an exception for it in your dependency-cruiser configuration. By default ' + + 'this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration ' + + 'files (.d.ts), tsconfig.json and some of the babel and webpack configs.', + severity: 'warn', + from: { + orphan: true, + pathNot: [ + '(^|/).[^/]+.(js|cjs|mjs|ts|json)$', // dot files + '.d.ts$', // TypeScript declaration files + '(^|/)tsconfig.json$', // TypeScript config + '(^|/)(babel|webpack).config.(js|cjs|mjs|ts|json)$', // other configs + ], + }, + to: {}, + }, + { + name: 'no-deprecated-core', + comment: + 'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + + "bound to exist - node doesn't deprecate lightly.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['core'], + path: [ + '^(v8/tools/codemap)$', + '^(v8/tools/consarray)$', + '^(v8/tools/csvparser)$', + '^(v8/tools/logreader)$', + '^(v8/tools/profile_view)$', + '^(v8/tools/profile)$', + '^(v8/tools/SourceMap)$', + '^(v8/tools/splaytree)$', + '^(v8/tools/tickprocessor-driver)$', + '^(v8/tools/tickprocessor)$', + '^(node-inspect/lib/_inspect)$', + '^(node-inspect/lib/internal/inspect_client)$', + '^(node-inspect/lib/internal/inspect_repl)$', + '^(async_hooks)$', + '^(punycode)$', + '^(domain)$', + '^(constants)$', + '^(sys)$', + '^(_linklist)$', + '^(_stream_wrap)$', + ], + }, + }, + { + name: 'not-to-deprecated', + comment: + 'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + + 'version of that module, or find an alternative. Deprecated modules are a security risk.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['deprecated'], + }, + }, + { + name: 'no-non-package-json', + severity: 'error', + comment: + "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + + "That's problematic as the package either (1) won't be available on live (2 - worse) will be " + + 'available on live with an non-guaranteed version. Fix it by adding the package to the dependencies ' + + 'in your package.json.', + from: {}, + to: { + dependencyTypes: ['npm-no-pkg', 'npm-unknown'], + }, + }, + { + name: 'not-to-unresolvable', + comment: + "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + + 'module: add it to your package.json. In all other cases you likely already know what to do.', + severity: 'error', + from: {}, + to: { + couldNotResolve: true, + }, + }, + { + name: 'no-duplicate-dep-types', + comment: + "Likely this module depends on an external ('npm') package that occurs more than once " + + 'in your package.json i.e. bot as a devDependencies and in dependencies. This will cause ' + + 'maintenance problems later on.', + severity: 'warn', + from: {}, + to: { + moreThanOneDependencyType: true, + // as it's pretty common to have a type import be a type only import + // _and_ (e.g.) a devDependency - don't consider type-only dependency + // types for this rule + dependencyTypesNot: ['type-only'], + }, + }, + + /* rules you might want to tweak for your specific situation: */ + + { + name: 'not-to-spec', + comment: + 'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + + "If there's something in a spec that's of use to other modules, it doesn't have that single " + + 'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', + severity: 'error', + from: {}, + to: { + path: '.(spec|test).(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee.md)$', + }, + }, + { + name: 'not-to-dev-dep', + severity: 'error', + comment: + "This module depends on an npm package from the 'devDependencies' section of your " + + 'package.json. It looks like something that ships to production, though. To prevent problems ' + + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + + 'section of your package.json. If this module is development only - add it to the ' + + 'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', + from: { + path: '^(src)', + pathNot: '.(spec|test).(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee.md)$', + }, + to: { + dependencyTypes: ['npm-dev'], + }, + }, + { + name: 'optional-deps-used', + severity: 'info', + comment: + 'This module depends on an npm package that is declared as an optional dependency ' + + "in your package.json. As this makes sense in limited situations only, it's flagged here. " + + "If you're using an optional dependency here by design - add an exception to your" + + 'dependency-cruiser configuration.', + from: {}, + to: { + dependencyTypes: ['npm-optional'], + }, + }, + { + name: 'peer-deps-used', + comment: + 'This module depends on an npm package that is declared as a peer dependency ' + + 'in your package.json. This makes sense if your package is e.g. a plugin, but in ' + + 'other cases - maybe not so much. If the use of a peer dependency is intentional ' + + 'add an exception to your dependency-cruiser configuration.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['npm-peer'], + }, + }, + ], + options: { + /* conditions specifying which files not to follow further when encountered: + - path: a regular expression to match + - dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/main/doc/rules-reference.md#dependencytypes-and-dependencytypesnot + for a complete list + */ + doNotFollow: { + path: 'node_modules', + }, + + /* conditions specifying which dependencies to exclude + - path: a regular expression to match + - dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies. + leave out if you want to exclude neither (recommended!) + */ + // exclude : { + // path: '', + // dynamic: true + // }, + + /* pattern specifying which files to include (regular expression) + dependency-cruiser will skip everything not matching this pattern + */ + // includeOnly : '', + + /* dependency-cruiser will include modules matching against the focus + regular expression in its output, as well as their neighbours (direct + dependencies and dependents) + */ + // focus : '', + + /* list of module systems to cruise */ + // moduleSystems: ['amd', 'cjs', 'es6', 'tsd'], + + /* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/' + to open it on your online repo or `vscode://file/${process.cwd()}/` to + open it in visual studio code), + */ + // prefix: '', + + /* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation + true: also detect dependencies that only exist before typescript-to-javascript compilation + "specify": for each dependency identify whether it only exists before compilation or also after + */ + tsPreCompilationDeps: true, + + /* + list of extensions to scan that aren't javascript or compile-to-javascript. + Empty by default. Only put extensions in here that you want to take into + account that are _not_ parsable. + */ + // extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"], + + /* if true combines the package.jsons found from the module up to the base + folder the cruise is initiated from. Useful for how (some) mono-repos + manage dependencies & dependency definitions. + */ + // combinedDependencies: false, + + /* if true leave symlinks untouched, otherwise use the realpath */ + // preserveSymlinks: false, + + /* TypeScript project file ('tsconfig.json') to use for + (1) compilation and + (2) resolution (e.g. with the paths property) + + The (optional) fileName attribute specifies which file to take (relative to + dependency-cruiser's current working directory). When not provided + defaults to './tsconfig.json'. + */ + tsConfig: { + fileName: 'tsconfig.json', + }, + + /* Webpack configuration to use to get resolve options from. + + The (optional) fileName attribute specifies which file to take (relative + to dependency-cruiser's current working directory. When not provided defaults + to './webpack.conf.js'. + + The (optional) `env` and `arguments` attributes contain the parameters to be passed if + your webpack config is a function and takes them (see webpack documentation + for details) + */ + // webpackConfig: { + // fileName: 'webpack.config.js', + // env: {}, + // arguments: {} + // }, + + /* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use + for compilation (and whatever other naughty things babel plugins do to + source code). This feature is well tested and usable, but might change + behavior a bit over time (e.g. more precise results for used module + systems) without dependency-cruiser getting a major version bump. + */ + // babelConfig: { + // fileName: '.babelrc', + // }, + + /* List of strings you have in use in addition to cjs/ es6 requires + & imports to declare module dependencies. Use this e.g. if you've + re-declared require, use a require-wrapper or use window.require as + a hack. + */ + // exoticRequireStrings: [], + /* options to pass on to enhanced-resolve, the package dependency-cruiser + uses to resolve module references to disk. You can set most of these + options in a webpack.conf.js - this section is here for those + projects that don't have a separate webpack config file. + + Note: settings in webpack.conf.js override the ones specified here. + */ + enhancedResolveOptions: { + /* List of strings to consider as 'exports' fields in package.json. Use + ['exports'] when you use packages that use such a field and your environment + supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack). + + If you have an `exportsFields` attribute in your webpack config, that one + will have precedence over the one specified here. + */ + exportsFields: ['exports'], + /* List of conditions to check for in the exports field. e.g. use ['imports'] + if you're only interested in exposed es6 modules, ['require'] for commonjs, + or all conditions at once `(['import', 'require', 'node', 'default']`) + if anything goes for you. Only works when the 'exportsFields' array is + non-empty. + + If you have a 'conditionNames' attribute in your webpack config, that one will + have precedence over the one specified here. + */ + conditionNames: ['import', 'require', 'node', 'default'], + /* + The extensions, by default are the same as the ones dependency-cruiser + can access (run `npx depcruise --info` to see which ones that are in + _your_ environment. If that list is larger than what you need (e.g. + it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use + TypeScript you can pass just the extensions you actually use (e.g. + [".js", ".jsx"]). This can speed up the most expensive step in + dependency cruising (module resolution) quite a bit. + */ + // extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"], + /* + If your TypeScript project makes use of types specified in 'types' + fields in package.jsons of external dependencies, specify "types" + in addition to "main" in here, so enhanced-resolve (the resolver + dependency-cruiser uses) knows to also look there. You can also do + this if you're not sure, but still use TypeScript. In a future version + of dependency-cruiser this will likely become the default. + */ + mainFields: ['main', 'types'], + }, + reporterOptions: { + dot: { + /* pattern of modules that can be consolidated in the detailed + graphical dependency graph. The default pattern in this configuration + collapses everything in node_modules to one folder deep so you see + the external modules, but not the innards your app depends upon. + */ + collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + don't worry - dependency-cruiser will fall back to the default one. + */ + // theme: { + // graph: { + // /* use splines: "ortho" for straight lines. Be aware though + // graphviz might take a long time calculating ortho(gonal) + // routings. + // */ + // splines: "true" + // }, + // modules: [ + // { + // criteria: { matchesFocus: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesFocus: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { matchesReaches: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesReaches: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { source: "^src/model" }, + // attributes: { fillcolor: "#ccccff" } + // }, + // { + // criteria: { source: "^src/view" }, + // attributes: { fillcolor: "#ccffcc" } + // }, + // ], + // dependencies: [ + // { + // criteria: { "rules[0].severity": "error" }, + // attributes: { fontcolor: "red", color: "red" } + // }, + // { + // criteria: { "rules[0].severity": "warn" }, + // attributes: { fontcolor: "orange", color: "orange" } + // }, + // { + // criteria: { "rules[0].severity": "info" }, + // attributes: { fontcolor: "blue", color: "blue" } + // }, + // { + // criteria: { resolved: "^src/model" }, + // attributes: { color: "#0000ff77" } + // }, + // { + // criteria: { resolved: "^src/view" }, + // attributes: { color: "#00770077" } + // } + // ] + // } + }, + archi: { + /* pattern of modules that can be consolidated in the high level + graphical dependency graph. If you use the high level graphical + dependency graph reporter (`archi`) you probably want to tweak + this collapsePattern to your situation. + */ + collapsePattern: '^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + for 'archi' dependency-cruiser will use the one specified in the + dot section (see above), if any, and otherwise use the default one. + */ + // theme: { + // }, + }, + text: { + highlightFocused: true, + }, + }, + }, +}; +// generated: dependency-cruiser@13.1.3 on 2023-08-17T07:59:01.225Z diff --git a/packages/data-sql/package.json b/packages/data-sql/package.json index ecb45b4894..9c5a634825 100644 --- a/packages/data-sql/package.json +++ b/packages/data-sql/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsup src/index.ts --format=esm --dts", "dev": "tsup src/index.ts --format=esm --dts --watch", - "test": "vitest --watch=false" + "test": "vitest --watch=false", + "depcruise": "depcruise src --include-only '^src' -x test.ts --output-type dot | dot -T svg > dependency-graph.svg" }, "description": "Shared SQL helpers for SQL-based drivers", "repository": { @@ -30,10 +31,16 @@ "@directus/random": "workspace:*", "@directus/tsconfig": "workspace:*", "@directus/types": "workspace:*", + "@types/lodash-es": "4.17.7", "@types/node": "18.16.12", + "@types/wellknown": "0.5.4", "@vitest/coverage-c8": "0.31.1", + "dependency-cruiser": "13.1.4", "tsup": "7.2.0", "typescript": "5.2.2", "vitest": "0.31.1" + }, + "dependencies": { + "lodash-es": "4.17.21" } } diff --git a/packages/data-sql/readme.md b/packages/data-sql/readme.md new file mode 100644 index 0000000000..e08e2c1ce0 --- /dev/null +++ b/packages/data-sql/readme.md @@ -0,0 +1,21 @@ +# `@directus/data-sql` + +A package which all SQL drivers use. Is consists out of three individual parts: + +- A set of types, which defines the abstract SQL query language. +- A query converter, which converts an abstract query into the abstract SQL query. +- A database response converter which converts the flat database response into a nested object in regards to tables that + have been joined. It also replaces the actual column name from the database, with an user specified alias if one was + provided. +- Some smaller utility functions, like for converting operators into SQL equivalents + +## Installation + +``` +npm install @directus/data-sql +``` + +## Current architecture of this package + +To get an overview of how the package is organized regarding it's files, directories and the dependencies between them, +run `pnpm run depcruise` and have a look in the created `dependency-graph.svg` image. diff --git a/packages/data-sql/src/converter/convert-filter.test.ts b/packages/data-sql/src/converter/convert-filter.test.ts deleted file mode 100644 index f740a43b70..0000000000 --- a/packages/data-sql/src/converter/convert-filter.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { - AbstractQueryFieldNodePrimitive, - AbstractQueryFilterNode, - AbstractQueryNodeCondition, - AbstractQueryNodeConditionValue, -} from '@directus/data'; -import { randomIdentifier, randomInteger } from '@directus/random'; -import { expect, test, beforeEach } from 'vitest'; -import { convertFilter } from './convert-filter.js'; -import type { AbstractSqlQueryWhereConditionNode, AbstractSqlQueryWhereLogicalNode } from '../types.js'; -import { parameterIndexGenerator } from '../utils/param-index-generator.js'; - -let sample: { - condition: AbstractQueryNodeCondition; - randomCollection: string; -}; - -beforeEach(() => { - sample = { - condition: { - type: 'condition', - target: { - type: 'primitive', - field: randomIdentifier(), - }, - operation: 'gt', - compareTo: { - type: 'value', - value: randomInteger(1, 100), - }, - }, - randomCollection: randomIdentifier(), - }; -}); - -test('Convert filter with one parameter', () => { - const idGen = parameterIndexGenerator(); - - const expectedWhere: AbstractSqlQueryWhereConditionNode = { - type: 'condition', - negate: false, - target: { - column: (sample.condition.target as AbstractQueryFieldNodePrimitive).field, - table: sample.randomCollection, - type: 'primitive', - }, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }; - - expect(convertFilter(sample.condition, sample.randomCollection, idGen)).toStrictEqual({ - where: expectedWhere, - parameters: [(sample.condition.compareTo as AbstractQueryNodeConditionValue).value], - }); -}); - -test.skip('Convert filter with one parameter and negation', () => { - // sample.condition.negate = true; - const idGen = parameterIndexGenerator(); - - const expectedWhere: AbstractSqlQueryWhereConditionNode = { - type: 'condition', - negate: true, - target: { - column: (sample.condition.target as AbstractQueryFieldNodePrimitive).field, - table: sample.randomCollection, - type: 'primitive', - }, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }; - - expect(convertFilter(sample.condition, sample.randomCollection, idGen)).toStrictEqual({ - where: expectedWhere, - parameters: [(sample.condition.compareTo as AbstractQueryNodeConditionValue).value], - }); -}); - -test.skip('Convert filter with two parameters', () => { - // sample.condition.operation = 'between'; - const idGen = parameterIndexGenerator(); - - expect(convertFilter(sample.condition, sample.randomCollection, idGen)).toStrictEqual({ - where: { - type: 'condition', - negation: false, - target: { - column: (sample.condition.target as AbstractQueryFieldNodePrimitive).field, - table: sample.randomCollection, - type: 'primitive', - }, - operation: 'between', - compareTo: { - type: 'value', - parameterIndexes: [0, 1], - }, - }, - parameters: [(sample.condition.compareTo as AbstractQueryNodeConditionValue).value], - }); -}); - -test('Convert filter with logical', () => { - const idGen = parameterIndexGenerator(); - - const randomCollection = randomIdentifier(); - - const firstField = randomIdentifier(); - const secondField = randomIdentifier(); - const firstValue = randomInteger(1, 100); - const secondValue = randomInteger(1, 100); - - const filter: AbstractQueryFilterNode = { - type: 'logical', - operator: 'or', - childNodes: [ - { - type: 'condition', - target: { - type: 'primitive', - field: firstField, - }, - operation: 'gt', - compareTo: { - type: 'value', - value: firstValue, - }, - }, - { - type: 'condition', - target: { - type: 'primitive', - field: secondField, - }, - operation: 'eq', - compareTo: { - type: 'value', - value: secondValue, - }, - }, - ], - }; - - const result = convertFilter(filter, randomCollection, idGen); - - const expectedWhere: AbstractSqlQueryWhereLogicalNode = { - type: 'logical', - operator: 'or', - negate: false, - childNodes: [ - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomCollection, - column: firstField, - }, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }, - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomCollection, - column: secondField, - }, - operation: 'eq', - compareTo: { - type: 'value', - parameterIndexes: [1], - }, - }, - ], - }; - - expect(result).toStrictEqual({ - where: expectedWhere, - parameters: [firstValue, secondValue], - }); -}); - -test('Convert filter nested and with negation', () => { - const idGen = parameterIndexGenerator(); - - const randomCollection = randomIdentifier(); - - const firstField = randomIdentifier(); - const secondField = randomIdentifier(); - const thirdField = randomIdentifier(); - const fourthField = randomIdentifier(); - - const firstValue = randomInteger(1, 100); - const secondValue = randomInteger(1, 100); - const thirdValue = randomInteger(1, 100); - const fourthValue = randomInteger(1, 100); - - // "firstField" > 1 OR NOT "secondField" = 2 OR NOT (NOT "thirdField" < 3 AND NOT (NOT ("fourthField" = 4))) - const filter: AbstractQueryFilterNode = { - type: 'logical', - operator: 'or', - childNodes: [ - { - type: 'condition', - target: { - type: 'primitive', - field: firstField, - }, - operation: 'gt', - compareTo: { - type: 'value', - value: firstValue, - }, - }, - { - type: 'negate', - childNode: { - type: 'condition', - target: { - type: 'primitive', - field: secondField, - }, - operation: 'eq', - compareTo: { - type: 'value', - value: secondValue, - }, - }, - }, - { - type: 'negate', - childNode: { - type: 'logical', - operator: 'and', - childNodes: [ - { - type: 'negate', - childNode: { - type: 'condition', - target: { - type: 'primitive', - field: thirdField, - }, - operation: 'lt', - compareTo: { - type: 'value', - value: thirdValue, - }, - }, - }, - { - type: 'negate', - childNode: { - type: 'negate', - childNode: { - type: 'condition', - target: { - type: 'primitive', - field: fourthField, - }, - operation: 'eq', - compareTo: { - type: 'value', - value: fourthValue, - }, - }, - }, - }, - ], - }, - }, - ], - }; - - const result = convertFilter(filter, randomCollection, idGen); - - const expectedWhere: AbstractSqlQueryWhereLogicalNode = { - type: 'logical', - operator: 'or', - negate: false, - childNodes: [ - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomCollection, - column: firstField, - }, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }, - { - type: 'condition', - negate: true, - target: { - type: 'primitive', - table: randomCollection, - column: secondField, - }, - operation: 'eq', - compareTo: { - type: 'value', - parameterIndexes: [1], - }, - }, - { - type: 'logical', - operator: 'and', - negate: true, - childNodes: [ - { - type: 'condition', - negate: true, - target: { - type: 'primitive', - table: randomCollection, - column: thirdField, - }, - operation: 'lt', - compareTo: { - type: 'value', - parameterIndexes: [2], - }, - }, - { - type: 'condition', - negate: false, - target: { - type: 'primitive', - table: randomCollection, - column: fourthField, - }, - operation: 'eq', - compareTo: { - type: 'value', - parameterIndexes: [3], - }, - }, - ], - }, - ], - }; - - expect(result).toStrictEqual({ - where: expectedWhere, - parameters: [firstValue, secondValue, thirdValue, fourthValue], - }); -}); diff --git a/packages/data-sql/src/converter/convert-filter.ts b/packages/data-sql/src/converter/convert-filter.ts deleted file mode 100644 index bc646f91d1..0000000000 --- a/packages/data-sql/src/converter/convert-filter.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { AbstractQueryFilterNode } from '@directus/data'; -import type { AbstractSqlQuery } from '../types.js'; - -/** - * Extracts the filer values and replaces it with parameter indexes. - * - * @param filter - all filter conditions - * @param collection - the name of the collection - * @param firstParameterIndex - The index of the parameter. Mandatory for all operators. - * @param secondParameterIndex - The index of an additional parameter. Only needed for some operators like BETWEEN. - * @returns - */ -export const convertFilter = ( - filter: AbstractQueryFilterNode, - collection: string, - generator: Generator -): Required> => { - return convertFilterWithNegate(filter, collection, generator, false); -}; - -const convertFilterWithNegate = ( - filter: AbstractQueryFilterNode, - collection: string, - generator: Generator, - negate: boolean -): Required> => { - if (filter.type === 'condition') { - if (filter.target.type !== 'primitive') { - /** @todo */ - throw new Error('Only primitives are currently supported.'); - } - - if (filter.operation === 'intersects' || filter.operation === 'intersects_bounding_box') { - /** @todo */ - throw new Error('The intersects operators are not yet supported.'); - } - - return { - where: { - type: 'condition', - negate, - operation: filter.operation, - target: { - column: filter.target.field, - table: collection, - type: 'primitive', - }, - compareTo: { - type: 'value', - parameterIndexes: [generator.next().value], - }, - }, - parameters: [filter.compareTo.value], - }; - } else if (filter.type === 'negate') { - return convertFilterWithNegate(filter.childNode, collection, generator, !negate); - } else { - const children = filter.childNodes.map((childNode) => - convertFilterWithNegate(childNode, collection, generator, false) - ); - - return { - where: { - type: 'logical', - negate, - operator: filter.operator, - childNodes: children.map((child) => child.where), - }, - parameters: children.flatMap((child) => child.parameters), - }; - } -}; diff --git a/packages/data-sql/src/converter/convert-primitive.test.ts b/packages/data-sql/src/converter/convert-primitive.test.ts deleted file mode 100644 index 6caf9e728e..0000000000 --- a/packages/data-sql/src/converter/convert-primitive.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AbstractQueryFieldNodePrimitive } from '@directus/data'; -import { beforeEach, expect, test } from 'vitest'; -import { convertPrimitive } from './convert-primitive.js'; -import { randomIdentifier } from '@directus/random'; - -let sample: { - node: AbstractQueryFieldNodePrimitive; - collection: string; -}; - -beforeEach(() => { - sample = { - node: { - type: 'primitive', - field: randomIdentifier(), - }, - collection: randomIdentifier(), - }; -}); - -test('get all selects', () => { - const res = convertPrimitive(sample.node, sample.collection); - - const expected = { - type: 'primitive', - table: sample.collection, - column: sample.node.field, - }; - - expect(res).toStrictEqual(expected); -}); diff --git a/packages/data-sql/src/converter/index.test.ts b/packages/data-sql/src/converter/index.test.ts deleted file mode 100644 index 87d79912a2..0000000000 --- a/packages/data-sql/src/converter/index.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { - AbstractQuery, - AbstractQueryFieldNodePrimitive, - AbstractQueryNodeCondition, - AbstractQueryNodeConditionValue, -} from '@directus/data'; -import { beforeEach, expect, test } from 'vitest'; -import type { AbstractSqlQuery } from '../types.js'; -import { convertAbstractQueryToAbstractSqlQuery } from './index.js'; -import { randomIdentifier, randomInteger } from '@directus/random'; - -let sample: { - query: AbstractQuery; -}; - -beforeEach(() => { - sample = { - query: { - root: true, - store: randomIdentifier(), - collection: randomIdentifier(), - nodes: [ - { - type: 'primitive', - field: randomIdentifier(), - }, - { - type: 'primitive', - field: randomIdentifier(), - }, - ], - }, - }; -}); - -test('Convert simple query', () => { - const res = convertAbstractQueryToAbstractSqlQuery(sample.query); - - const expected: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[0] as AbstractQueryFieldNodePrimitive).field, - }, - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[1] as AbstractQueryFieldNodePrimitive).field, - }, - ], - from: sample.query.collection, - parameters: [], - }; - - expect(res).toStrictEqual(expected); -}); - -test('Convert query with filter', () => { - sample.query.modifiers = { - filter: { - type: 'condition', - target: { - type: 'primitive', - field: randomIdentifier(), - }, - operation: 'gt', - compareTo: { - type: 'value', - value: randomInteger(1, 100), - }, - }, - }; - - const res = convertAbstractQueryToAbstractSqlQuery(sample.query); - - const expected: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[0] as AbstractQueryFieldNodePrimitive).field, - }, - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[1] as AbstractQueryFieldNodePrimitive).field, - }, - ], - from: sample.query.collection, - where: { - type: 'condition', - target: { - column: ( - (sample.query.modifiers.filter as AbstractQueryNodeCondition).target as AbstractQueryFieldNodePrimitive - ).field, - table: sample.query.collection, - type: 'primitive', - }, - negate: false, - operation: 'gt', - compareTo: { - type: 'value', - parameterIndexes: [0], - }, - }, - parameters: [ - ((sample.query.modifiers.filter! as AbstractQueryNodeCondition).compareTo as AbstractQueryNodeConditionValue) - .value, - ], - }; - - expect(res).toStrictEqual(expected); -}); - -test('Convert query with a limit', () => { - sample.query.modifiers = { - limit: { - type: 'limit', - value: randomInteger(1, 100), - }, - }; - - const res = convertAbstractQueryToAbstractSqlQuery(sample.query); - - const expected: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[0] as AbstractQueryFieldNodePrimitive).field, - }, - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[1] as AbstractQueryFieldNodePrimitive).field, - }, - ], - from: sample.query.collection, - limit: { parameterIndex: 0 }, - parameters: [sample.query.modifiers.limit!.value], - }; - - expect(res).toStrictEqual(expected); -}); - -test('Convert query with limit and offset', () => { - sample.query.modifiers = { - limit: { - type: 'limit', - value: randomInteger(1, 100), - }, - offset: { - type: 'offset', - value: randomInteger(1, 100), - }, - }; - - const res = convertAbstractQueryToAbstractSqlQuery(sample.query); - - const expected: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[0] as AbstractQueryFieldNodePrimitive).field, - }, - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[1] as AbstractQueryFieldNodePrimitive).field, - }, - ], - from: sample.query.collection, - limit: { parameterIndex: 0 }, - offset: { parameterIndex: 1 }, - parameters: [sample.query.modifiers.limit!.value, sample.query.modifiers.offset!.value], - }; - - expect(res).toStrictEqual(expected); -}); - -test('Convert query with a sort', () => { - sample.query.modifiers = { - sort: [ - { - type: 'sort', - direction: 'ascending', - target: { - type: 'primitive', - field: randomIdentifier(), - }, - }, - ], - }; - - const res = convertAbstractQueryToAbstractSqlQuery(sample.query); - - const expected: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[0] as AbstractQueryFieldNodePrimitive).field, - }, - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[1] as AbstractQueryFieldNodePrimitive).field, - }, - ], - from: sample.query.collection, - order: [ - { - orderBy: sample.query.modifiers.sort![0]!.target, - direction: 'ASC', - }, - ], - parameters: [], - }; - - expect(res).toStrictEqual(expected); -}); - -test('Convert a query with all possible modifiers', () => { - sample.query.modifiers = { - limit: { - type: 'limit', - value: randomInteger(1, 100), - }, - offset: { - type: 'offset', - value: randomInteger(1, 100), - }, - sort: [ - { - type: 'sort', - direction: 'ascending', - target: { - type: 'primitive', - field: randomIdentifier(), - }, - }, - ], - }; - - const res = convertAbstractQueryToAbstractSqlQuery(sample.query); - - const expected: AbstractSqlQuery = { - select: [ - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[0] as AbstractQueryFieldNodePrimitive).field, - }, - { - type: 'primitive', - table: sample.query.collection, - column: (sample.query.nodes[1] as AbstractQueryFieldNodePrimitive).field, - }, - ], - from: sample.query.collection, - order: [ - { - orderBy: sample.query.modifiers.sort![0]!.target, - direction: 'ASC', - }, - ], - limit: { parameterIndex: 0 }, - offset: { parameterIndex: 1 }, - parameters: [sample.query.modifiers.limit!.value, sample.query.modifiers.offset!.value], - }; - - expect(res).toStrictEqual(expected); -}); diff --git a/packages/data-sql/src/converter/index.ts b/packages/data-sql/src/converter/index.ts deleted file mode 100644 index 88c555a480..0000000000 --- a/packages/data-sql/src/converter/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { AbstractQuery } from '@directus/data'; -import type { AbstractSqlQuery } from '../types.js'; -import { convertPrimitive } from './convert-primitive.js'; -import { parameterIndexGenerator } from '../utils/param-index-generator.js'; -import { convertSort } from './convert-sort.js'; -import { convertFilter } from './convert-filter.js'; - -/** - * @param abstractQuery the abstract query to convert - * @returns a format very close to actual SQL but without making assumptions about the actual SQL dialect - */ -export const convertAbstractQueryToAbstractSqlQuery = (abstractQuery: AbstractQuery): AbstractSqlQuery => { - const statement: AbstractSqlQuery = { - select: abstractQuery.nodes.map((abstractNode) => { - switch (abstractNode.type) { - case 'primitive': - return convertPrimitive(abstractNode, abstractQuery.collection); - case 'fn': - case 'm2o': - case 'o2m': - case 'a2o': - case 'o2a': - default: - throw new Error(`Type ${abstractNode.type} hasn't been implemented yet`); - } - }), - from: abstractQuery.collection, - parameters: [], - }; - - const idGen = parameterIndexGenerator(); - - if (abstractQuery.modifiers?.filter) { - const convertedFilter = convertFilter(abstractQuery.modifiers.filter, abstractQuery.collection, idGen); - - statement.where = convertedFilter.where; - statement.parameters.push(...convertedFilter.parameters); - } - - // TODO: Create a generic function for this and add unit tests. This way we might can save some tests in index.test.ts - - if (abstractQuery.modifiers?.limit) { - statement.limit = { parameterIndex: idGen.next().value }; - statement.parameters.push(abstractQuery.modifiers.limit.value); - } - - if (abstractQuery.modifiers?.offset) { - statement.offset = { parameterIndex: idGen.next().value }; - statement.parameters.push(abstractQuery.modifiers.offset.value); - } - - if (abstractQuery.modifiers?.sort) { - statement.order = convertSort(abstractQuery.modifiers.sort); - } - - return statement; -}; diff --git a/packages/data-sql/src/index.ts b/packages/data-sql/src/index.ts index b96b9635ee..58e992d085 100644 --- a/packages/data-sql/src/index.ts +++ b/packages/data-sql/src/index.ts @@ -1,14 +1,4 @@ -/** - * A package which can bee seen as middleware for SQL drivers. - * Its purpose is to convert a very abstract query into a more SQL specific query. - * However in this package aren't any assumptions made about an concrete SQL dialect. - * - * @remarks - * This packages comes in handy especially regarding converting abstract relationships into actual JOINs. - * It eliminates redundant logic since this would otherwise be implemented in every SQL driver. - * - * @packageDocumentation - */ - -export * from './converter/index.js'; -export * from './types.js'; +export * from './query-converter/index.js'; +export * from './orm/index.js'; +export * from './types/index.js'; +export * from './utils/index.js'; diff --git a/packages/data-sql/src/orm/create-unique-alias.ts b/packages/data-sql/src/orm/create-unique-alias.ts new file mode 100644 index 0000000000..0228087a5d --- /dev/null +++ b/packages/data-sql/src/orm/create-unique-alias.ts @@ -0,0 +1,17 @@ +import { randomBytes } from 'node:crypto'; + +/** + * Appends a pseudo-random value to the end of a given identifier to make sure it's unique within the + * context of the current query. The generated identifier is used as an alias to select columns and to join tables. + * + * @remarks + * The uniqueness of a table or column within the schema is not enough for us, since f.e. the same table can be joined multiple times + * and only with some randomness added, we can ensure that the ORM does the nesting correctly. + * + * @todo OracleDB has a max length of 30 characters for identifiers. Is this the right spot to + * ensure that, or should that be on the DB level? + */ +export const createUniqueAlias = (identifier: string) => { + const random = randomBytes(3).toString('hex'); + return `${identifier}_${random}`; +}; diff --git a/packages/data-sql/src/orm/expand.test.ts b/packages/data-sql/src/orm/expand.test.ts new file mode 100644 index 0000000000..739d91b99e --- /dev/null +++ b/packages/data-sql/src/orm/expand.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from 'vitest'; +import { transformChunk } from './expand.js'; +import { randomAlpha } from '@directus/random'; + +test('response with no relation', () => { + const randomTitle = randomAlpha(25); + + const res = transformChunk( + { + alias1: 1, // id + alias2: randomTitle, // title + }, + new Map([ + ['alias1', ['id']], + ['alias2', ['title']], + ]) + ); + + expect(res).toEqual({ + id: 1, + title: randomTitle, + }); +}); + +test('expand response with one nested table', () => { + const res = transformChunk( + { + a1: 1, + a2: 1, + a3: 'John', + a4: 'Doe', + }, + new Map([ + ['a1', ['id']], + ['a2', ['users', 'id']], + ['a3', ['users', 'first_name']], + ['a4', ['users', 'last_name']], + ]) + ); + + expect(res).toEqual({ + id: 1, + users: { + id: 1, + first_name: 'John', + last_name: 'Doe', + }, + }); +}); + +test('expand response with one nested table and a function on a nested field ', () => { + const res = transformChunk( + { + a1: 1, + a2: 1, + a3: 'John', + a4: 'Doe', + }, + new Map([ + ['a1', ['id']], + ['a2', ['users', 'id']], + ['a3', ['users', 'first_name']], + ['a4', ['users', 'last_name']], + ]) + ); + + expect(res).toEqual({ + id: 1, + users: { + id: 1, + first_name: 'John', + last_name: 'Doe', + }, + }); +}); + +test('expand response with multiple nested tables', () => { + const res = transformChunk( + { + a1: 1, + a2: 1, + a3: 'John', + a4: 'Doe', + a5: 'somewhere', + }, + new Map([ + ['a1', ['id']], + ['a2', ['users', 'id']], + ['a3', ['users', 'first_name']], + ['a4', ['users', 'last_name']], + ['a5', ['users', 'cities', 'name']], + ]) + ); + + expect(res).toEqual({ + id: 1, + users: { + id: 1, + first_name: 'John', + last_name: 'Doe', + cities: { + name: 'somewhere', + }, + }, + }); +}); diff --git a/packages/data-sql/src/orm/expand.ts b/packages/data-sql/src/orm/expand.ts new file mode 100644 index 0000000000..9c8f69dc8a --- /dev/null +++ b/packages/data-sql/src/orm/expand.ts @@ -0,0 +1,44 @@ +import { set } from 'lodash-es'; +import { TransformStream } from 'node:stream/web'; + +/** + * Converts the receiving chunks from the database into a nested structure + * based on the result from the database. + */ +export const getOrmTransformer = (paths: Map): TransformStream => { + return new TransformStream({ + transform(chunk, controller) { + if (chunk?.constructor !== Object) { + throw new Error(`Can't expand a non-object chunk`); + } + + const outputChunk = transformChunk(chunk, paths); + controller.enqueue(outputChunk); + }, + }); +}; + +/** + * It takes the chunk from the stream and transforms the + * flat result from the database (basically a two dimensional matrix) + * into to proper nested javascript object. + * + * @param chunk one row of the database response + * @param paths the lookup map from the aliases to the nested path + * @returns an object which reflects the hierarchy from the initial query + */ +export function transformChunk(chunk: Record, paths: Map): Record { + const result = {}; + + for (const [key, value] of Object.entries(chunk)) { + const path = paths.get(key); + + if (!path) { + throw new Error(`No path available for dot-notated key ${key}`); + } + + set(result, path, value); + } + + return result; +} diff --git a/packages/data-sql/src/orm/index.ts b/packages/data-sql/src/orm/index.ts new file mode 100644 index 0000000000..3c92030287 --- /dev/null +++ b/packages/data-sql/src/orm/index.ts @@ -0,0 +1,43 @@ +/** + * This unit takes care of transforming the response from the database into into a proper JSON object. + * An SQL database returns the result as a table, where the actual data are the rows and the columns specify the fields. + * This gets converted by the JS/TS drivers into an object, where the properties are the columns and the values the according value in the row. + * Therefore we always receive an flat object which we in turn want to transform into a nested object, when another table is joined. + * + * @example + * Let's say we have the two collections articles and authors and we want to query all articles with all data about the author as well. + * This would be the SQL query: + * + * ```sql + * select * from articles join authors on authors.id = articles.author; + * ``` + * The following is a chunk from an example response from the database: + * ```json + * { + * "id": 1, + * "status": "published", + * "author": 1, + * "title": "some news", + * "name": "jan" + * }, + * ``` + * The first four rows were stored in the articles table, the last two in the authors table. + * But what we want to return to the user is the following: + * ```json + * { + * "id": 1, + * "status": "published", + * "title": "some news", + * "author": { + * "id": 1, + * "name": "jan" + * }, + * }, + * ``` + * That's what this unit is for. + * + * @module + * @packageDocumentation + */ +export * from './expand.js'; +export * from './create-unique-alias.js'; diff --git a/packages/data-sql/src/query-converter/converter.test.ts b/packages/data-sql/src/query-converter/converter.test.ts new file mode 100644 index 0000000000..6ddd9b21e5 --- /dev/null +++ b/packages/data-sql/src/query-converter/converter.test.ts @@ -0,0 +1,333 @@ +import type { AbstractQuery, AbstractQueryFieldNodePrimitive } from '@directus/data'; +import { beforeEach, expect, test } from 'vitest'; +import type { AbstractSqlQuery } from '../types/index.js'; +import { convertQuery } from './converter.js'; +import { randomIdentifier, randomInteger } from '@directus/random'; + +let sample: AbstractQuery; + +beforeEach(() => { + sample = { + root: true, + store: randomIdentifier(), + collection: randomIdentifier(), + fields: [ + { + type: 'primitive', + field: randomIdentifier(), + }, + { + type: 'primitive', + field: randomIdentifier(), + }, + ], + }; +}); + +test('Convert simple query', () => { + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + ], + from: sample.collection, + }, + parameters: [], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); + +test('Convert query with filter', () => { + const randomField = randomIdentifier(); + const compareToValue = randomInteger(1, 100); + + sample.modifiers = { + filter: { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField, + }, + operation: 'gt', + compareTo: compareToValue, + }, + }, + }; + + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + ], + from: sample.collection, + where: { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + column: randomField, + table: sample.collection, + type: 'primitive', + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }, + }, + parameters: [compareToValue], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); + +test('Convert query with a limit', () => { + sample.modifiers = { + limit: { + type: 'limit', + value: randomInteger(1, 100), + }, + }; + + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + ], + from: sample.collection, + limit: { type: 'value', parameterIndex: 0 }, + }, + parameters: [sample.modifiers.limit!.value], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); + +test('Convert query with limit and offset', () => { + sample.modifiers = { + limit: { + type: 'limit', + value: randomInteger(1, 100), + }, + offset: { + type: 'offset', + value: randomInteger(1, 100), + }, + }; + + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + ], + from: sample.collection, + limit: { type: 'value', parameterIndex: 0 }, + offset: { type: 'value', parameterIndex: 1 }, + }, + parameters: [sample.modifiers.limit!.value, sample.modifiers.offset!.value], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); + +test('Convert query with a sort', () => { + sample.modifiers = { + sort: [ + { + type: 'sort', + direction: 'ascending', + target: { + type: 'primitive', + field: randomIdentifier(), + }, + }, + ], + }; + + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + ], + from: sample.collection, + order: [ + { + type: 'order', + orderBy: sample.modifiers.sort![0]!.target, + direction: 'ASC', + }, + ], + }, + parameters: [], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); + +test('Convert a query with a function as field select', () => { + const randomField = randomIdentifier(); + + sample.fields.push({ + type: 'fn', + fn: { + type: 'arrayFn', + fn: 'count', + }, + field: randomField, + }); + + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'fn', + fn: { + type: 'arrayFn', + fn: 'count', + }, + table: sample.collection, + column: randomField, + }, + ], + from: sample.collection, + }, + parameters: [], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); + +test('Convert a query with all possible modifiers', () => { + sample.modifiers = { + limit: { + type: 'limit', + value: randomInteger(1, 100), + }, + offset: { + type: 'offset', + value: randomInteger(1, 100), + }, + sort: [ + { + type: 'sort', + direction: 'ascending', + target: { + type: 'primitive', + field: randomIdentifier(), + }, + }, + ], + }; + + const res = convertQuery(sample); + + const expected: Required> = { + clauses: { + select: [ + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[0] as AbstractQueryFieldNodePrimitive).field, + }, + { + type: 'primitive', + table: sample.collection, + column: (sample.fields[1] as AbstractQueryFieldNodePrimitive).field, + }, + ], + from: sample.collection, + order: [ + { + type: 'order', + orderBy: sample.modifiers.sort![0]!.target, + direction: 'ASC', + }, + ], + limit: { type: 'value', parameterIndex: 0 }, + offset: { type: 'value', parameterIndex: 1 }, + }, + parameters: [sample.modifiers.limit!.value, sample.modifiers.offset!.value], + }; + + expect(res.clauses).toMatchObject(expected.clauses); + expect(res.parameters).toMatchObject(expected.parameters); +}); diff --git a/packages/data-sql/src/query-converter/converter.ts b/packages/data-sql/src/query-converter/converter.ts new file mode 100644 index 0000000000..bb11e12cac --- /dev/null +++ b/packages/data-sql/src/query-converter/converter.ts @@ -0,0 +1,41 @@ +/** + * Converts an abstract query to the abstract SQL query ({@link AbstractSqlClauses}). + * This converter is used as the first action within the SQL drivers. + * + * @module + */ +import type { AbstractQuery } from '@directus/data'; +import type { AbstractSqlClauses, AbstractSqlQuery } from '../types/index.js'; +import type { ParameterTypes } from '../types/parameterized-statement.js'; +import { parameterIndexGenerator } from './param-index-generator.js'; +import { convertFieldNodes } from './fields/index.js'; +import { convertModifiers } from './modifiers/modifiers.js'; + +/** + * Here the abstract query gets converted into the abstract SQL query. + * It calls all related conversion functions and takes care of the parameter index. + * This process, is also part of the ORM since here the aliases get generated and the mapping of aliases to the original fields is created. + * + * @param abstractQuery the abstract query to convert + * @returns the abstract sql query + */ +export const convertQuery = (abstractQuery: AbstractQuery): AbstractSqlQuery => { + const idGen = parameterIndexGenerator(); + const parameters: ParameterTypes[] = []; + + // fields + const convertedFieldNodes = convertFieldNodes(abstractQuery.collection, abstractQuery.fields, idGen); + let clauses: AbstractSqlClauses = { ...convertedFieldNodes.clauses, from: abstractQuery.collection }; + parameters.push(...convertedFieldNodes.parameters); + + // modifiers + const convertedModifiers = convertModifiers(abstractQuery.modifiers, abstractQuery.collection, idGen); + clauses = Object.assign(clauses, convertedModifiers.clauses); + parameters.push(...convertedModifiers.parameters); + + return { + clauses, + parameters, + aliasMapping: convertedFieldNodes.aliasMapping, + }; +}; diff --git a/packages/data-sql/src/query-converter/fields/create-join.test.ts b/packages/data-sql/src/query-converter/fields/create-join.test.ts new file mode 100644 index 0000000000..1e3f5f3439 --- /dev/null +++ b/packages/data-sql/src/query-converter/fields/create-join.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from 'vitest'; +import { randomIdentifier } from '@directus/random'; +import type { AbstractQueryFieldNodeRelatedManyToOne } from '@directus/data'; +import { createJoin } from './create-join.js'; +import type { AbstractSqlQueryJoinNode } from '../../types/clauses/joins/join.js'; + +test('Convert m2o relation on single field ', () => { + const randomCurrentCollection = randomIdentifier(); + const randomCurrentField = randomIdentifier(); + const randomExternalCollection = randomIdentifier(); + const randomExternalStore = randomIdentifier(); + const randomExternalField = randomIdentifier(); + const randomExternalSelectField = randomIdentifier(); + const randomAlias = randomIdentifier(); + + const node: AbstractQueryFieldNodeRelatedManyToOne = { + type: 'm2o', + join: { + current: { + fields: [randomCurrentField], + }, + external: { + store: randomExternalStore, + collection: randomExternalCollection, + fields: [randomExternalField], + }, + }, + fields: [ + { + type: 'primitive', + field: randomExternalSelectField, + }, + ], + }; + + const expected: AbstractSqlQueryJoinNode = { + type: 'join', + table: randomExternalCollection, + on: { + type: 'condition', + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: randomCurrentCollection, + column: randomCurrentField, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: randomAlias, + column: randomExternalField, + }, + }, + negate: false, + }, + as: randomAlias, + }; + + expect(createJoin(randomCurrentCollection, node, randomAlias)).toStrictEqual(expected); +}); + +test('Convert m2o relation with composite keys', () => { + const randomCurrentCollection = randomIdentifier(); + const randomCurrentField = randomIdentifier(); + const randomCurrentField2 = randomIdentifier(); + const randomExternalCollection = randomIdentifier(); + const randomExternalStore = randomIdentifier(); + const randomExternalField = randomIdentifier(); + const randomExternalField2 = randomIdentifier(); + const randomExternalSelectField = randomIdentifier(); + const randomGeneratedAlias = randomIdentifier(); + const randomUserAlias = randomIdentifier(); + + const node: AbstractQueryFieldNodeRelatedManyToOne = { + type: 'm2o', + join: { + current: { + fields: [randomCurrentField, randomCurrentField2], + }, + external: { + store: randomExternalStore, + collection: randomExternalCollection, + fields: [randomExternalField, randomExternalField2], + }, + }, + fields: [ + { + type: 'primitive', + field: randomExternalSelectField, + }, + ], + alias: randomUserAlias, + }; + + const expected: AbstractSqlQueryJoinNode = { + type: 'join', + table: randomExternalCollection, + on: { + type: 'logical', + operator: 'and', + negate: false, + childNodes: [ + { + type: 'condition', + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: randomCurrentCollection, + column: randomCurrentField, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: randomGeneratedAlias, + column: randomExternalField, + }, + }, + negate: false, + }, + { + type: 'condition', + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: randomCurrentCollection, + column: randomCurrentField2, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: randomGeneratedAlias, + column: randomExternalField2, + }, + }, + negate: false, + }, + ], + }, + as: randomGeneratedAlias, + alias: randomUserAlias, + }; + + expect(createJoin(randomCurrentCollection, node, randomGeneratedAlias)).toStrictEqual(expected); +}); diff --git a/packages/data-sql/src/query-converter/fields/create-join.ts b/packages/data-sql/src/query-converter/fields/create-join.ts new file mode 100644 index 0000000000..b009e89787 --- /dev/null +++ b/packages/data-sql/src/query-converter/fields/create-join.ts @@ -0,0 +1,74 @@ +import type { AbstractQueryFieldNodeRelatedManyToOne } from '@directus/data'; +import type { AbstractSqlQueryConditionNode, AbstractSqlQueryLogicalNode } from '../../types/index.js'; +import type { AbstractSqlQueryJoinNode } from '../../types/index.js'; + +export const createJoin = ( + currentCollection: string, + relationalField: AbstractQueryFieldNodeRelatedManyToOne, + externalCollectionAlias: string +): AbstractSqlQueryJoinNode => { + let on: AbstractSqlQueryLogicalNode | AbstractSqlQueryConditionNode; + + if (relationalField.join.current.fields.length > 1) { + on = { + type: 'logical', + operator: 'and', + negate: false, + childNodes: relationalField.join.current.fields.map((currentField, index) => { + const externalField = relationalField.join.external.fields[index]; + + if (!externalField) { + throw new Error(`Missing related foreign key join column for current context column "${currentField}"`); + } + + return getJoinCondition(currentCollection, currentField, externalCollectionAlias, externalField); + }), + }; + } else { + on = getJoinCondition( + currentCollection, + relationalField.join.current.fields[0], + externalCollectionAlias, + relationalField.join.external.fields[0] + ); + } + + const result: AbstractSqlQueryJoinNode = { + type: 'join', + table: relationalField.join.external.collection, + as: externalCollectionAlias, + on, + }; + + if (relationalField.alias) { + result.alias = relationalField.alias; + } + + return result; +}; + +function getJoinCondition( + table1: string, + column1: string, + table2: string, + column2: string +): AbstractSqlQueryConditionNode { + return { + type: 'condition', + negate: false, + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: table1, + column: column1, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: table2, + column: column2, + }, + }, + }; +} diff --git a/packages/data-sql/src/query-converter/fields/create-primitive-select.test.ts b/packages/data-sql/src/query-converter/fields/create-primitive-select.test.ts new file mode 100644 index 0000000000..0be856a11b --- /dev/null +++ b/packages/data-sql/src/query-converter/fields/create-primitive-select.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, expect, test } from 'vitest'; +import { createPrimitiveSelect } from './create-primitive-select.js'; +import type { AbstractQueryFieldNodePrimitive } from '@directus/data'; +import { randomIdentifier } from '@directus/random'; + +let randomPrimitiveField: string; +let collection: string; +let fieldAlias: string; + +beforeEach(() => { + randomPrimitiveField = randomIdentifier(); + collection = randomIdentifier(); + fieldAlias = `${randomPrimitiveField}_RANDOM`; +}); + +test('createPrimitiveSelect', () => { + const randomPrimitiveField = randomIdentifier(); + const collection = randomIdentifier(); + const fieldAlias = `${randomPrimitiveField}_RANDOM`; + + const samplePrimitiveNode: AbstractQueryFieldNodePrimitive = { + type: 'primitive', + field: randomPrimitiveField, + }; + + const result = createPrimitiveSelect(collection, samplePrimitiveNode, fieldAlias); + + expect(result).toStrictEqual({ + type: 'primitive', + table: collection, + column: randomPrimitiveField, + as: fieldAlias, + }); +}); + +test('createPrimitiveSelect with user specified alias', () => { + const randomUserAlias = randomIdentifier(); + + const samplePrimitiveNode: AbstractQueryFieldNodePrimitive = { + type: 'primitive', + field: randomPrimitiveField, + alias: randomUserAlias, + }; + + const result = createPrimitiveSelect(collection, samplePrimitiveNode, fieldAlias); + + expect(result).toStrictEqual({ + type: 'primitive', + table: collection, + column: randomPrimitiveField, + as: fieldAlias, + alias: randomUserAlias, + }); +}); diff --git a/packages/data-sql/src/converter/convert-primitive.ts b/packages/data-sql/src/query-converter/fields/create-primitive-select.ts similarity index 51% rename from packages/data-sql/src/converter/convert-primitive.ts rename to packages/data-sql/src/query-converter/fields/create-primitive-select.ts index 539c6be260..1a03947cb2 100644 --- a/packages/data-sql/src/converter/convert-primitive.ts +++ b/packages/data-sql/src/query-converter/fields/create-primitive-select.ts @@ -1,24 +1,26 @@ import type { AbstractQueryFieldNodePrimitive } from '@directus/data'; -import type { SqlStatementSelectColumn } from '../types.js'; +import type { AbstractSqlQuerySelectNode } from '../../types/index.js'; /** * @param abstractPrimitive * @param collection * @returns the converted primitive node */ -export const convertPrimitive = ( +export const createPrimitiveSelect = ( + collection: string, abstractPrimitive: AbstractQueryFieldNodePrimitive, - collection: string -): SqlStatementSelectColumn => { - const statement: SqlStatementSelectColumn = { + generatedAlias: string +): AbstractSqlQuerySelectNode => { + const primitive: AbstractSqlQuerySelectNode = { type: 'primitive', table: collection, column: abstractPrimitive.field, + as: generatedAlias, }; if (abstractPrimitive.alias) { - statement.as = abstractPrimitive.alias; + primitive.alias = abstractPrimitive.alias; } - return statement; + return primitive; }; diff --git a/packages/data-sql/src/query-converter/fields/fields.test.ts b/packages/data-sql/src/query-converter/fields/fields.test.ts new file mode 100644 index 0000000000..4ab8ef5f1e --- /dev/null +++ b/packages/data-sql/src/query-converter/fields/fields.test.ts @@ -0,0 +1,229 @@ +import { expect, test, vi, afterAll, beforeEach } from 'vitest'; +import { convertFieldNodes, type Result } from './fields.js'; +import { parameterIndexGenerator } from '../param-index-generator.js'; +import type { AbstractQueryFieldNode } from '@directus/data'; +import { randomIdentifier } from '@directus/random'; + +afterAll(() => { + vi.restoreAllMocks(); +}); + +vi.mock('../../orm/create-unique-alias.js', () => ({ + createUniqueAlias: vi.fn().mockImplementation((i) => `${i}_RANDOM`), +})); + +let randomPrimitiveField1: string; +let randomPrimitiveField2: string; +let randomCollection: string; + +beforeEach(() => { + randomPrimitiveField1 = randomIdentifier(); + randomPrimitiveField2 = randomIdentifier(); + randomCollection = randomIdentifier(); +}); + +test('primitives only', () => { + const fields: AbstractQueryFieldNode[] = [ + { + type: 'primitive', + field: randomPrimitiveField1, + }, + { + type: 'primitive', + field: randomPrimitiveField2, + }, + ]; + + const expected: Result = { + clauses: { + select: [ + { + type: 'primitive', + table: randomCollection, + column: randomPrimitiveField1, + as: `${randomPrimitiveField1}_RANDOM`, + }, + { + type: 'primitive', + table: randomCollection, + column: randomPrimitiveField2, + as: `${randomPrimitiveField2}_RANDOM`, + }, + ], + joins: [], + }, + parameters: [], + aliasMapping: new Map([ + [`${randomPrimitiveField1}_RANDOM`, [randomPrimitiveField1]], + [`${randomPrimitiveField2}_RANDOM`, [randomPrimitiveField2]], + ]), + }; + + const idGen = parameterIndexGenerator(); + const result = convertFieldNodes(randomCollection, fields, idGen); + expect(result.clauses).toMatchObject(expected.clauses); + expect(result.parameters).toMatchObject(expected.parameters); + expect(result.aliasMapping).toMatchObject(expected.aliasMapping); +}); + +test('primitive and function', () => { + const fields: AbstractQueryFieldNode[] = [ + { + type: 'primitive', + field: randomPrimitiveField1, + }, + { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + field: randomPrimitiveField2, + }, + ]; + + const expected: Result = { + clauses: { + select: [ + { + type: 'primitive', + table: randomCollection, + column: randomPrimitiveField1, + as: `${randomPrimitiveField1}_RANDOM`, + }, + { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + table: randomCollection, + column: randomPrimitiveField2, + as: `month_${randomPrimitiveField2}_RANDOM`, + }, + ], + joins: [], + }, + parameters: [], + aliasMapping: new Map([ + [`${randomPrimitiveField1}_RANDOM`, [randomPrimitiveField1]], + [`month_${randomPrimitiveField2}_RANDOM`, [randomPrimitiveField2]], + ]), + }; + + const idGen = parameterIndexGenerator(); + const result = convertFieldNodes(randomCollection, fields, idGen); + expect(result.clauses).toMatchObject(expected.clauses); + expect(result.parameters).toMatchObject(expected.parameters); + expect(result.aliasMapping).toMatchObject(expected.aliasMapping); +}); + +test('primitive, fn, m2o', () => { + const randomJoinCurrentField = randomIdentifier(); + const randomExternalCollection = randomIdentifier(); + const randomExternalStore = randomIdentifier(); + const randomExternalField = randomIdentifier(); + const randomJoinNodeField = randomIdentifier(); + const randomPrimitiveFieldFn = randomIdentifier(); + + const fields: AbstractQueryFieldNode[] = [ + { + type: 'primitive', + field: randomPrimitiveField1, + }, + { + type: 'm2o', + join: { + current: { + fields: [randomJoinCurrentField], + }, + external: { + store: randomExternalStore, + collection: randomExternalCollection, + fields: [randomExternalField], + }, + }, + fields: [ + { + type: 'primitive', + field: randomJoinNodeField, + }, + ], + }, + { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + field: randomPrimitiveFieldFn, + }, + ]; + + const idGen = parameterIndexGenerator(); + + const expected: Result = { + clauses: { + select: [ + { + type: 'primitive', + table: randomCollection, + column: randomPrimitiveField1, + as: `${randomPrimitiveField1}_RANDOM`, + }, + { + type: 'primitive', + table: `${randomExternalCollection}_RANDOM`, + column: randomJoinNodeField, + as: `${randomJoinNodeField}_RANDOM`, + }, + { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + table: randomCollection, + column: `${randomPrimitiveFieldFn}`, + as: `month_${randomPrimitiveFieldFn}_RANDOM`, + }, + ], + joins: [ + { + type: 'join', + table: randomExternalCollection, + on: { + type: 'condition', + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: randomCollection, + column: randomJoinCurrentField, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: `${randomExternalCollection}_RANDOM`, + column: randomExternalField, + }, + }, + negate: false, + }, + as: `${randomExternalCollection}_RANDOM`, + }, + ], + }, + parameters: [], + aliasMapping: new Map([ + [`${randomPrimitiveField1}_RANDOM`, [randomPrimitiveField1]], + [`${randomJoinNodeField}_RANDOM`, [randomExternalCollection, randomJoinNodeField]], + [`month_${randomPrimitiveFieldFn}_RANDOM`, [randomPrimitiveFieldFn]], + ]), + }; + + const result = convertFieldNodes(randomCollection, fields, idGen); + expect(result.clauses).toMatchObject(expected.clauses); + expect(result.parameters).toMatchObject(expected.parameters); + expect(result.aliasMapping).toMatchObject(expected.aliasMapping); +}); diff --git a/packages/data-sql/src/query-converter/fields/fields.ts b/packages/data-sql/src/query-converter/fields/fields.ts new file mode 100644 index 0000000000..1939677be0 --- /dev/null +++ b/packages/data-sql/src/query-converter/fields/fields.ts @@ -0,0 +1,93 @@ +import type { AbstractQueryFieldNode } from '@directus/data'; +import type { AbstractSqlClauses, AbstractSqlQuery, ParameterTypes } from '../../types/index.js'; +import { createPrimitiveSelect } from './create-primitive-select.js'; +import { createJoin } from './create-join.js'; +import { convertFn } from '../functions.js'; +import { createUniqueAlias } from '../../orm/create-unique-alias.js'; + +export type Result = { + clauses: Pick; + parameters: AbstractSqlQuery['parameters']; + aliasMapping: AbstractSqlQuery['aliasMapping']; +}; + +/** + * Converts nodes into the abstract sql clauses. + * Any primitive nodes and function nodes will be added to the list of selects. + * Any m2o node will be added to the list of joins and the desired column will also be added to the list of selects. + * + * Also some preparation work is done here regarding the ORM. + * For this, each select node and the joined tables will get an auto generated alias. + * While iterating over the nodes, the mapping of the auto generated alias to the original (related) field is created and returned separately. + * This map of aliases to the relational "path" will be used later on to convert the response to a nested object - the second part of the ORM. + * + * @param collection - the current collection, will be an alias when called recursively + * @param abstractFields - all nodes from the abstract query + * @param idxGenerator - the generator used to increase the parameter indices + * @param currentPath - the path which the recursion made for the ORM map + * @returns Select, join and parameters + */ +export const convertFieldNodes = ( + collection: string, + abstractFields: AbstractQueryFieldNode[], + idxGenerator: Generator, + currentPath: string[] = [] +): Result => { + const select: AbstractSqlClauses['select'] = []; + const joins: AbstractSqlClauses['joins'] = []; + const parameters: ParameterTypes[] = []; + const aliasRelationalMapping: Map = new Map(); + + for (const abstractField of abstractFields) { + if (abstractField.type === 'primitive') { + // ORM aliasing and mapping + const generatedAlias = createUniqueAlias(abstractField.field); + aliasRelationalMapping.set(generatedAlias, [...currentPath, abstractField.alias ?? abstractField.field]); + + // query conversion + const selectNode = createPrimitiveSelect(collection, abstractField, generatedAlias); + select.push(selectNode); + continue; + } + + if (abstractField.type === 'm2o') { + /** + * Always fetch the current context foreign key as well. We need it to check if the current + * item has a related item so we don't expand `null` values in a nested object where every + * value is null + * + * @TODO + */ + + const m2oField = abstractField; + const externalCollectionAlias = createUniqueAlias(m2oField.join.external.collection); + const sqlJoinNode = createJoin(collection, m2oField, externalCollectionAlias); + + const nestedOutput = convertFieldNodes(externalCollectionAlias, abstractField.fields, idxGenerator, [ + ...currentPath, + abstractField.join.external.collection, + ]); + + nestedOutput.aliasMapping.forEach((value, key) => aliasRelationalMapping.set(key, value)); + joins.push(sqlJoinNode); + select.push(...nestedOutput.clauses.select); + continue; + } + + if (abstractField.type === 'fn') { + const fnField = abstractField; + + // ORM aliasing and mapping + const generatedAlias = createUniqueAlias(`${fnField.fn.fn}_${fnField.field}`); + aliasRelationalMapping.set(generatedAlias, [...currentPath, abstractField.alias ?? abstractField.field]); + + // query conversion + const fn = convertFn(collection, fnField, idxGenerator, generatedAlias); + select.push(fn.fn); + parameters.push(...fn.parameters); + continue; + } + } + + return { clauses: { select, joins }, parameters, aliasMapping: aliasRelationalMapping }; +}; diff --git a/packages/data-sql/src/query-converter/fields/index.ts b/packages/data-sql/src/query-converter/fields/index.ts new file mode 100644 index 0000000000..fe2407f775 --- /dev/null +++ b/packages/data-sql/src/query-converter/fields/index.ts @@ -0,0 +1 @@ +export * from './fields.js'; diff --git a/packages/data-sql/src/query-converter/functions.test.ts b/packages/data-sql/src/query-converter/functions.test.ts new file mode 100644 index 0000000000..97b5c8ebab --- /dev/null +++ b/packages/data-sql/src/query-converter/functions.test.ts @@ -0,0 +1,116 @@ +import type { AbstractQueryFieldNodeFn } from '@directus/data'; +import { randomAlpha, randomIdentifier } from '@directus/random'; +import { describe, expect, test, beforeEach } from 'vitest'; +import type { AbstractSqlQueryFnNode } from '../types/clauses/selects/fn.js'; +import { parameterIndexGenerator } from './param-index-generator.js'; +import { convertFn } from './functions.js'; + +let randomCollection: string; +let idGen: Generator; +let sampleField: string; + +beforeEach(() => { + randomCollection = randomIdentifier(); + idGen = parameterIndexGenerator(); + sampleField = randomIdentifier(); +}); + +describe('Convert function', () => { + test('With no args', () => { + const sampleAbstractFn: AbstractQueryFieldNodeFn = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + field: sampleField, + }; + + const res = convertFn(randomCollection, sampleAbstractFn, idGen); + + const expectedSqlFn: AbstractSqlQueryFnNode = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + table: randomCollection, + column: sampleField, + }; + + expect(res).toStrictEqual({ + fn: expectedSqlFn, + parameters: [], + }); + }); + + test('With an generated alias and user alias', () => { + const uniqueId = randomIdentifier(); + const randomUserAlias = randomIdentifier(); + + const sampleAbstractFn: AbstractQueryFieldNodeFn = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + field: sampleField, + alias: randomUserAlias, + }; + + const res = convertFn(randomCollection, sampleAbstractFn, idGen, uniqueId); + + const expectedSqlFn: AbstractSqlQueryFnNode = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + table: randomCollection, + column: sampleField, + alias: randomUserAlias, + as: uniqueId, + }; + + expect(res).toStrictEqual({ + fn: expectedSqlFn, + parameters: [], + }); + }); + + test('With args', () => { + const randomArgument1 = randomAlpha(5); + const randomArgument2 = randomAlpha(5); + + const sampleFn: AbstractQueryFieldNodeFn = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + field: sampleField, + args: [randomArgument1, randomArgument2], + }; + + const res = convertFn(randomCollection, sampleFn, idGen); + + const sampleSqlFn: AbstractSqlQueryFnNode = { + type: 'fn', + fn: { + type: 'extractFn', + fn: 'month', + }, + table: randomCollection, + column: sampleField, + arguments: { + type: 'values', + parameterIndexes: [0, 1], + }, + }; + + expect(res).toStrictEqual({ + fn: sampleSqlFn, + parameters: [randomArgument1, randomArgument2], + }); + }); +}); diff --git a/packages/data-sql/src/query-converter/functions.ts b/packages/data-sql/src/query-converter/functions.ts new file mode 100644 index 0000000000..66c6c9d837 --- /dev/null +++ b/packages/data-sql/src/query-converter/functions.ts @@ -0,0 +1,42 @@ +import type { AbstractQueryFieldNodeFn } from '@directus/data'; +import type { ParameterTypes, ValuesNode, AbstractSqlQueryFnNode } from '../types/index.js'; + +/** + * @param collection + * @param abstractFunction - the function node to convert + * @param idxGenerator - the generator to get the next index in the parameter list + * @param generatedAlias - a generated alias which needs to be specified when to function is used within the select clause + */ +export function convertFn( + collection: string, + abstractFunction: AbstractQueryFieldNodeFn, + idxGenerator: Generator, + generatedAlias?: string +): { fn: AbstractSqlQueryFnNode; parameters: ParameterTypes[] } { + const fn: AbstractSqlQueryFnNode = { + type: 'fn', + fn: abstractFunction.fn, + table: collection, + column: abstractFunction.field, + }; + + if (abstractFunction.alias) { + fn.alias = abstractFunction.alias; + } + + if (generatedAlias) { + fn.as = generatedAlias; + } + + if (abstractFunction.args && abstractFunction.args?.length > 0) { + fn.arguments = { + type: 'values', + parameterIndexes: abstractFunction.args.map(() => idxGenerator.next().value), + } as ValuesNode; + } + + return { + fn, + parameters: abstractFunction.args ?? [], + }; +} diff --git a/packages/data-sql/src/query-converter/index.ts b/packages/data-sql/src/query-converter/index.ts new file mode 100644 index 0000000000..93c40f1e86 --- /dev/null +++ b/packages/data-sql/src/query-converter/index.ts @@ -0,0 +1 @@ +export * from './converter.js'; diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/conditions.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/conditions.test.ts new file mode 100644 index 0000000000..062d830783 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/conditions.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, expect, test, vi, afterEach, describe } from 'vitest'; +import { convertCondition } from './conditions.js'; +import { randomIdentifier } from '@directus/random'; +import { parameterIndexGenerator } from '../../../param-index-generator.js'; +import type { AbstractQueryConditionNode } from '@directus/data'; +import { convertStringNode } from './string.js'; +import { convertNumberNode } from './number.js'; +import { convertSetCondition } from './set.js'; +import { convertGeoCondition } from './geo.js'; + +let sample: AbstractQueryConditionNode; +let randomCollection: string; +let generator: Generator; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + sample = { + type: 'condition', + // @ts-ignore - the only prop which is relevant here for the test + condition: { + type: 'condition-string', + }, + }; + + randomCollection = randomIdentifier(); + generator = parameterIndexGenerator(); +}); + +test('Convert string condition', () => { + vi.mock('./string.js', () => ({ + convertStringNode: vi.fn(), + })); + + convertCondition(sample, randomCollection, generator, false); + expect(convertStringNode).toHaveBeenCalledOnce(); +}); + +test('Convert number condition', () => { + sample.condition.type = 'condition-number'; + + vi.mock('./number.js', () => ({ + convertNumberNode: vi.fn(), + })); + + convertCondition(sample, randomCollection, generator, false); + expect(convertNumberNode).toHaveBeenCalledOnce(); +}); + +test('Convert set condition', () => { + sample.condition.type = 'condition-set'; + + vi.mock('./set.js', () => ({ + convertSetCondition: vi.fn(), + })); + + convertCondition(sample, randomCollection, generator, false); + expect(convertSetCondition).toHaveBeenCalledOnce(); +}); + +describe('Convert field condition', () => { + vi.mock('./geo.js', () => ({ + convertGeoCondition: vi.fn(), + })); + + test('Convert geo points and lines condition', () => { + sample.condition.type = 'condition-geo-intersects'; + convertCondition(sample, randomCollection, generator, false); + expect(convertGeoCondition).toHaveBeenCalledOnce(); + }); + + test('Convert geo points and lines condition', () => { + sample.condition.type = 'condition-geo-intersects-bbox'; + convertCondition(sample, randomCollection, generator, false); + expect(convertGeoCondition).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/conditions.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/conditions.ts new file mode 100644 index 0000000000..423b8ab931 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/conditions.ts @@ -0,0 +1,31 @@ +import type { AbstractQueryConditionNode } from '@directus/data'; +import type { WhereUnion } from '../../../../types/index.js'; +import { convertFieldCondition } from './field.js'; +import { convertGeoCondition } from './geo.js'; +import { convertStringNode } from './string.js'; +import { convertNumberNode } from './number.js'; +import { convertSetCondition } from './set.js'; + +/** + * Forward the condition to the correct converter. + */ +export function convertCondition( + condition: AbstractQueryConditionNode, + collection: string, + generator: Generator, + negate: boolean +): WhereUnion { + switch (condition.condition.type) { + case 'condition-string': + return convertStringNode(condition.condition, collection, generator, negate); + case 'condition-number': + return convertNumberNode(condition.condition, collection, generator, negate); + case 'condition-geo-intersects': + case 'condition-geo-intersects-bbox': + return convertGeoCondition(condition.condition, collection, generator, negate); + case 'condition-set': + return convertSetCondition(condition.condition, collection, generator, negate); + case 'condition-field': + return convertFieldCondition(condition.condition, collection, negate); + } +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/field.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/field.test.ts new file mode 100644 index 0000000000..29e08a14bb --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/field.test.ts @@ -0,0 +1,50 @@ +import type { ConditionFieldNode } from '@directus/data'; +import { randomIdentifier } from '@directus/random'; +import { expect, test } from 'vitest'; +import { convertFieldCondition } from './field.js'; +import type { AbstractSqlQueryConditionNode } from '../../../../types/clauses/where/index.js'; + +test('number', () => { + const randomCollection1 = randomIdentifier(); + const randomCollection2 = randomIdentifier(); + const randomField1 = randomIdentifier(); + const randomField2 = randomIdentifier(); + + const con: ConditionFieldNode = { + type: 'condition-field', + target: { + type: 'primitive', + field: randomField1, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + field: randomField2, + collection: randomCollection2, + }, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + negate: false, + condition: { + type: 'condition-field', + target: { + type: 'primitive', + table: randomCollection1, + column: randomField1, + }, + operation: 'eq', + compareTo: { + type: 'primitive', + table: randomCollection2, + column: randomField2, + }, + }, + }; + + expect(convertFieldCondition(con, randomCollection1, false)).toStrictEqual({ + where: expectedWhere, + parameters: [], + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/field.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/field.ts new file mode 100644 index 0000000000..9a5e8fdd52 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/field.ts @@ -0,0 +1,19 @@ +import type { ConditionFieldNode } from '@directus/data'; +import type { WhereUnion } from '../../../../types/index.js'; +import { convertPrimitive } from './utils.js'; + +export function convertFieldCondition(node: ConditionFieldNode, collection: string, negate: boolean): WhereUnion { + return { + where: { + type: 'condition', + negate, + condition: { + type: 'condition-field', + operation: node.operation, + target: convertPrimitive(collection, node.target), + compareTo: convertPrimitive(node.compareTo.collection, node.compareTo), + }, + }, + parameters: [], + }; +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/geo.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/geo.test.ts new file mode 100644 index 0000000000..5863c8ff00 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/geo.test.ts @@ -0,0 +1,77 @@ +import type { ConditionGeoIntersectsBBoxNode } from '@directus/data'; +import { randomIdentifier } from '@directus/random'; +import { expect, test } from 'vitest'; +import type { GeoJSONGeometry } from 'wellknown'; +import type { AbstractSqlQueryConditionNode } from '../../../../types/clauses/where/index.js'; +import { parameterIndexGenerator } from '../../../param-index-generator.js'; +import { convertGeoCondition } from './geo.js'; + +test('geo', () => { + const idGen = parameterIndexGenerator(); + const randomCollection = randomIdentifier(); + const randomField = randomIdentifier(); + + const gisValue: GeoJSONGeometry = { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0], + ], + ], + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ], + [ + [100.2, 0.2], + [100.2, 0.8], + [100.8, 0.8], + [100.8, 0.2], + [100.2, 0.2], + ], + ], + ], + }; + + const con: ConditionGeoIntersectsBBoxNode = { + type: 'condition-geo-intersects-bbox', + target: { + type: 'primitive', + field: randomField, + }, + operation: 'intersects_bbox', + compareTo: gisValue, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + condition: { + type: 'condition-geo', + target: { + type: 'primitive', + table: randomCollection, + column: randomField, + }, + operation: 'intersects_bbox', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + negate: false, + }; + + expect(convertGeoCondition(con, randomCollection, idGen, false)).toStrictEqual({ + where: expectedWhere, + parameters: [gisValue], + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/geo.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/geo.ts new file mode 100644 index 0000000000..0680716836 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/geo.ts @@ -0,0 +1,27 @@ +import type { ConditionGeoIntersectsNode, ConditionGeoIntersectsBBoxNode } from '@directus/data'; +import type { WhereUnion } from '../../../../types/index.js'; +import { convertPrimitive } from './utils.js'; + +export function convertGeoCondition( + node: ConditionGeoIntersectsNode | ConditionGeoIntersectsBBoxNode, + collection: string, + generator: Generator, + negate: boolean +): WhereUnion { + return { + where: { + type: 'condition', + negate, + condition: { + type: 'condition-geo', + operation: node.operation, + target: convertPrimitive(collection, node.target), + compareTo: { + type: 'value', + parameterIndex: generator.next().value, + }, + }, + }, + parameters: [node.compareTo], + }; +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/number.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/number.test.ts new file mode 100644 index 0000000000..aa288abef8 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/number.test.ts @@ -0,0 +1,96 @@ +import type { ConditionNumberNode } from '@directus/data'; +import { randomIdentifier, randomInteger } from '@directus/random'; +import { expect, test, beforeEach } from 'vitest'; +import { parameterIndexGenerator } from '../../../param-index-generator.js'; +import { convertNumberNode } from './number.js'; +import type { AbstractSqlQueryConditionNode } from '../../../../index.js'; + +let idGen: Generator; +let randomCollection: string; +let randomField: string; +let randomValue: number; + +beforeEach(() => { + idGen = parameterIndexGenerator(); + randomCollection = randomIdentifier(); + randomField = randomIdentifier(); + randomValue = randomInteger(1, 100); +}); + +test('convert number condition', () => { + const con: ConditionNumberNode = { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField, + }, + operation: 'gt', + compareTo: randomValue, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomCollection, + column: randomField, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }; + + expect(convertNumberNode(con, randomCollection, idGen, false)).toStrictEqual({ + where: expectedWhere, + parameters: [randomValue], + }); +}); + +test('convert number condition with function', () => { + const con: ConditionNumberNode = { + type: 'condition-number', + target: { + type: 'fn', + field: randomField, + fn: { + type: 'extractFn', + fn: 'month', + }, + }, + operation: 'gt', + compareTo: randomValue, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'fn', + table: randomCollection, + column: randomField, + fn: { + type: 'extractFn', + fn: 'month', + }, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + negate: false, + }; + + expect(convertNumberNode(con, randomCollection, idGen, false)).toStrictEqual({ + where: expectedWhere, + parameters: [randomValue], + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/number.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/number.ts new file mode 100644 index 0000000000..5e3ac5a3a6 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/number.ts @@ -0,0 +1,27 @@ +import type { ConditionNumberNode } from '@directus/data'; +import type { WhereUnion } from '../../../../types/index.js'; +import { convertTarget } from './utils.js'; + +export function convertNumberNode( + node: ConditionNumberNode, + collection: string, + generator: Generator, + negate: boolean +): WhereUnion { + return { + where: { + type: 'condition', + negate, + condition: { + type: node.type, + operation: node.operation, + target: convertTarget(node, collection, generator), + compareTo: { + type: 'value', + parameterIndex: generator.next().value, + }, + }, + }, + parameters: [node.compareTo], + }; +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/set.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/set.test.ts new file mode 100644 index 0000000000..e644c3721e --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/set.test.ts @@ -0,0 +1,46 @@ +import type { ConditionSetNode } from '@directus/data'; +import { randomIdentifier, randomInteger } from '@directus/random'; +import { expect, test } from 'vitest'; +import type { AbstractSqlQueryConditionNode } from '../../../../types/clauses/where/index.js'; +import { parameterIndexGenerator } from '../../../param-index-generator.js'; +import { convertSetCondition } from './set.js'; + +test('set', () => { + const idGen = parameterIndexGenerator(); + const randomCollection = randomIdentifier(); + const randomField = randomIdentifier(); + const randomValues: number[] = [randomInteger(1, 100), randomInteger(1, 100), randomInteger(1, 100)]; + + const con: ConditionSetNode = { + type: 'condition-set', + target: { + type: 'primitive', + field: randomField, + }, + operation: 'in', + compareTo: randomValues, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + condition: { + type: 'condition-set', + target: { + type: 'primitive', + table: randomCollection, + column: randomField, + }, + operation: 'in', + compareTo: { + type: 'values', + parameterIndexes: [0, 1, 2], + }, + }, + negate: false, + }; + + expect(convertSetCondition(con, randomCollection, idGen, false)).toStrictEqual({ + where: expectedWhere, + parameters: randomValues, + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/set.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/set.ts new file mode 100644 index 0000000000..6a5595bc2d --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/set.ts @@ -0,0 +1,27 @@ +import type { ConditionSetNode } from '@directus/data'; +import type { WhereUnion } from '../../../../types/index.js'; +import { convertPrimitive } from './utils.js'; + +export function convertSetCondition( + node: ConditionSetNode, + collection: string, + generator: Generator, + negate: boolean +): WhereUnion { + return { + where: { + type: 'condition', + negate, + condition: { + type: 'condition-set', + operation: node.operation, + target: convertPrimitive(collection, node.target), + compareTo: { + type: 'values', + parameterIndexes: Array.from(node.compareTo).map(() => generator.next().value), + }, + }, + }, + parameters: [...node.compareTo], + }; +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/string.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/string.test.ts new file mode 100644 index 0000000000..cbdd6d321a --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/string.test.ts @@ -0,0 +1,46 @@ +import type { ConditionStringNode } from '@directus/data'; +import { randomIdentifier } from '@directus/random'; +import { expect, test } from 'vitest'; +import { parameterIndexGenerator } from '../../../param-index-generator.js'; +import { convertStringNode } from './string.js'; +import type { AbstractSqlQueryConditionNode } from '../../../../types/index.js'; + +test('number', () => { + const idGen = parameterIndexGenerator(); + const randomCollection = randomIdentifier(); + const randomField = randomIdentifier(); + const randomCompareValue = randomIdentifier(); + + const con: ConditionStringNode = { + type: 'condition-string', + target: { + type: 'primitive', + field: randomField, + }, + operation: 'contains', + compareTo: randomCompareValue, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + negate: false, + condition: { + type: 'condition-string', + target: { + type: 'primitive', + table: randomCollection, + column: randomField, + }, + operation: 'contains', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }; + + expect(convertStringNode(con, randomCollection, idGen, false)).toStrictEqual({ + where: expectedWhere, + parameters: [randomCompareValue], + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/string.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/string.ts new file mode 100644 index 0000000000..0a2b839011 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/string.ts @@ -0,0 +1,27 @@ +import type { ConditionStringNode } from '@directus/data'; +import type { WhereUnion } from '../../../../types/index.js'; +import { convertPrimitive } from './utils.js'; + +export function convertStringNode( + node: ConditionStringNode, + collection: string, + generator: Generator, + negate: boolean +): WhereUnion { + return { + where: { + type: 'condition', + negate, + condition: { + type: node.type, + operation: node.operation, + target: convertPrimitive(collection, node.target), + compareTo: { + type: 'value', + parameterIndex: generator.next().value, + }, + }, + }, + parameters: [node.compareTo], + }; +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/conditions/utils.ts b/packages/data-sql/src/query-converter/modifiers/filter/conditions/utils.ts new file mode 100644 index 0000000000..be2a778c53 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/conditions/utils.ts @@ -0,0 +1,45 @@ +import type { AbstractQueryFieldNodePrimitive, ActualConditionNodes } from '@directus/data'; +import type { ParameterTypes, AbstractSqlQueryFnNode, AbstractSqlQuerySelectNode } from '../../../../types/index.js'; +import { convertFn } from '../../../functions.js'; + +/** + * It adds the table name to the node. + * @param collection + * @param primitiveNode + * @returns an unambitious column + */ +export function convertPrimitive( + collection: string, + primitiveNode: AbstractQueryFieldNodePrimitive +): AbstractSqlQuerySelectNode { + return { + type: 'primitive', + table: collection, + column: primitiveNode.field, + }; +} + +export function convertTarget( + condition: ActualConditionNodes, + collection: string, + generator: Generator +): AbstractSqlQueryFnNode | AbstractSqlQuerySelectNode { + let target: AbstractSqlQueryFnNode | AbstractSqlQuerySelectNode; + const parameters: ParameterTypes[] = []; + + if (condition.target.type === 'primitive') { + target = { + type: 'primitive', + table: collection, + column: condition.target.field, + }; + } else if (condition.target.type === 'fn') { + const convertedFn = convertFn(collection, condition.target, generator); + target = convertedFn.fn; + parameters.push(...convertedFn.parameters); + } else { + throw new Error('The related field types are not yet supported.'); + } + + return target; +} diff --git a/packages/data-sql/src/query-converter/modifiers/filter/filter.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/filter.test.ts new file mode 100644 index 0000000000..f8ae18c35b --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/filter.test.ts @@ -0,0 +1,318 @@ +import type { AbstractQueryFilterNode } from '@directus/data'; +import { randomIdentifier, randomInteger, randomAlpha } from '@directus/random'; +import { beforeEach, expect, test, describe } from 'vitest'; +import { parameterIndexGenerator } from '../../param-index-generator.js'; +import { convertFilter } from './filter.js'; +import type { AbstractSqlQueryLogicalNode, AbstractSqlQueryConditionNode } from '../../../types/clauses/where/index.js'; + +let randomCollection: string; +let randomField1: string; +let randomField2: string; +let randomNumber1: number; +let randomString1: string; +let idxGen: Generator; +let randomCompareTo: number; + +beforeEach(() => { + randomCompareTo = randomInteger(1, 100); + randomCollection = randomIdentifier(); + randomField1 = randomIdentifier(); + randomField2 = randomIdentifier(); + randomNumber1 = randomInteger(1, 100); + randomString1 = randomAlpha(5); + idxGen = parameterIndexGenerator(); +}); + +test('Convert single filter', () => { + const sampleFilter: AbstractQueryFilterNode = { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField1, + }, + operation: 'gt', + compareTo: randomCompareTo, + }, + }; + + const expectedWhere: AbstractSqlQueryConditionNode = { + type: 'condition', + negate: true, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + column: randomField1, + table: randomCollection, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }; + + expect(convertFilter(sampleFilter, randomCollection, idxGen, true)).toStrictEqual({ + where: expectedWhere, + parameters: [randomCompareTo], + }); +}); + +describe('convert multiple conditions', () => { + test('Convert logical node with two conditions', () => { + const sampleFilter: AbstractQueryFilterNode = { + type: 'logical', + operator: 'and', + childNodes: [ + { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField1, + }, + operation: 'eq', + compareTo: randomNumber1, + }, + }, + { + type: 'condition', + condition: { + type: 'condition-string', + target: { + type: 'primitive', + field: randomField2, + }, + operation: 'starts_with', + compareTo: randomString1, + }, + }, + ], + }; + + const expectedWhere: AbstractSqlQueryLogicalNode = { + type: 'logical', + negate: false, + operator: 'and', + childNodes: [ + { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + column: randomField1, + table: randomCollection, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + negate: false, + }, + { + type: 'condition', + condition: { + type: 'condition-string', + target: { + type: 'primitive', + column: randomField2, + table: randomCollection, + }, + operation: 'starts_with', + compareTo: { + type: 'value', + parameterIndex: 1, + }, + }, + negate: false, + }, + ], + }; + + expect(convertFilter(sampleFilter, randomCollection, idxGen)).toStrictEqual({ + where: expectedWhere, + parameters: [randomNumber1, randomString1], + }); + }); + + test('Convert logical node with nested conditions and with negation', () => { + const randomField3 = randomIdentifier(); + const randomField4 = randomIdentifier(); + const randomNumber2 = randomInteger(1, 100); + const randomNumber3 = randomInteger(1, 100); + + // "firstField" > 1 OR NOT "secondField" = 2 OR NOT (NOT "thirdField" < 3 AND NOT (NOT ("fourthField" = 4))) + const filter: AbstractQueryFilterNode = { + type: 'logical', + operator: 'or', + childNodes: [ + { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField1, + }, + operation: 'gt', + compareTo: randomNumber1, + }, + }, + { + type: 'negate', + childNode: { + type: 'condition', + condition: { + type: 'condition-string', + target: { + type: 'primitive', + field: randomField2, + }, + operation: 'starts_with', + compareTo: randomString1, + }, + }, + }, + { + type: 'negate', + childNode: { + type: 'logical', + operator: 'and', + childNodes: [ + { + type: 'negate', + childNode: { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField3, + }, + operation: 'lt', + compareTo: randomNumber2, + }, + }, + }, + { + type: 'negate', + childNode: { + type: 'negate', + childNode: { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + field: randomField4, + }, + operation: 'eq', + compareTo: randomNumber3, + }, + }, + }, + }, + ], + }, + }, + ], + }; + + const result = convertFilter(filter, randomCollection, idxGen); + + const expectedWhere: AbstractSqlQueryLogicalNode = { + type: 'logical', + operator: 'or', + negate: false, + childNodes: [ + { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomCollection, + column: randomField1, + }, + operation: 'gt', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + }, + { + type: 'condition', + negate: true, + condition: { + type: 'condition-string', + target: { + type: 'primitive', + table: randomCollection, + column: randomField2, + }, + operation: 'starts_with', + compareTo: { + type: 'value', + parameterIndex: 1, + }, + }, + }, + { + type: 'logical', + operator: 'and', + negate: true, + childNodes: [ + { + type: 'condition', + negate: true, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomCollection, + column: randomField3, + }, + operation: 'lt', + compareTo: { + type: 'value', + parameterIndex: 2, + }, + }, + }, + { + type: 'condition', + negate: false, + condition: { + type: 'condition-number', + target: { + type: 'primitive', + table: randomCollection, + column: randomField4, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 3, + }, + }, + }, + ], + }, + ], + }; + + expect(result).toStrictEqual({ + where: expectedWhere, + parameters: [randomNumber1, randomString1, randomNumber2, randomNumber3], + }); + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/filter.ts b/packages/data-sql/src/query-converter/modifiers/filter/filter.ts new file mode 100644 index 0000000000..433b901ca5 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/filter.ts @@ -0,0 +1,33 @@ +import type { AbstractQueryConditionNode, AbstractQueryFilterNode } from '@directus/data'; +import type { WhereUnion } from '../../../types/index.js'; +import { convertCondition } from './conditions/conditions.js'; +import { convertLogical } from './logical.js'; + +/** + * Extracts the user provided filter values and puts them in the list of parameters. + * It also converts the negation format. + * This function is recursive. + * + * @param filter - the filter to apply + * @param collection - the name of the collection + * @param generator - the generator for the parameter index + * @param negate - whether the filter should be negated + * @returns + */ +export const convertFilter = ( + filter: AbstractQueryFilterNode, + collection: string, + generator: Generator, + negate = false +): WhereUnion => { + if (filter.type === 'condition') { + return convertCondition(filter as AbstractQueryConditionNode, collection, generator, negate); + } else if (filter.type === 'negate') { + return convertFilter(filter.childNode, collection, generator, !negate); + } else if (filter.type === 'logical') { + const children = filter.childNodes.map((childNode) => convertFilter(childNode, collection, generator, false)); + return convertLogical(children, filter.operator, negate); + } else { + throw new Error(`Unknown filter type`); + } +}; diff --git a/packages/data-sql/src/query-converter/modifiers/filter/index.ts b/packages/data-sql/src/query-converter/modifiers/filter/index.ts new file mode 100644 index 0000000000..4cd823c440 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/index.ts @@ -0,0 +1,2 @@ +export * from './logical.js'; +export * from './filter.js'; diff --git a/packages/data-sql/src/query-converter/modifiers/filter/logical.test.ts b/packages/data-sql/src/query-converter/modifiers/filter/logical.test.ts new file mode 100644 index 0000000000..b193a10eae --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/logical.test.ts @@ -0,0 +1,110 @@ +import { expect, test, beforeEach } from 'vitest'; +import { convertLogical } from './logical.js'; +import type { AbstractSqlQueryLogicalNode, WhereUnion } from '../../../index.js'; +import { randomAlpha, randomIdentifier, randomInteger } from '@directus/random'; + +let randomCollection: string; +let randomField1: string; +let randomField2: string; +let randomNumber1: number; +let randomString1: string; + +beforeEach(() => { + randomCollection = randomIdentifier(); + randomField1 = randomIdentifier(); + randomField2 = randomIdentifier(); + randomNumber1 = randomInteger(1, 100); + randomString1 = randomAlpha(5); +}); + +test('Convert logical node with two conditions', () => { + const children: WhereUnion[] = [ + { + where: { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + column: randomField1, + table: randomCollection, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + negate: false, + }, + parameters: [randomNumber1], + }, + { + where: { + type: 'condition', + condition: { + type: 'condition-string', + target: { + type: 'primitive', + column: randomField2, + table: randomCollection, + }, + operation: 'starts_with', + compareTo: { + type: 'value', + parameterIndex: 1, + }, + }, + negate: false, + }, + parameters: [randomString1], + }, + ]; + + const expectedWhere: AbstractSqlQueryLogicalNode = { + type: 'logical', + negate: false, + operator: 'and', + childNodes: [ + { + type: 'condition', + condition: { + type: 'condition-number', + target: { + type: 'primitive', + column: randomField1, + table: randomCollection, + }, + operation: 'eq', + compareTo: { + type: 'value', + parameterIndex: 0, + }, + }, + negate: false, + }, + { + type: 'condition', + condition: { + type: 'condition-string', + target: { + type: 'primitive', + column: randomField2, + table: randomCollection, + }, + operation: 'starts_with', + compareTo: { + type: 'value', + parameterIndex: 1, + }, + }, + negate: false, + }, + ], + }; + + expect(convertLogical(children, 'and', false)).toStrictEqual({ + where: expectedWhere, + parameters: [randomNumber1, randomString1], + }); +}); diff --git a/packages/data-sql/src/query-converter/modifiers/filter/logical.ts b/packages/data-sql/src/query-converter/modifiers/filter/logical.ts new file mode 100644 index 0000000000..594e61bb21 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/filter/logical.ts @@ -0,0 +1,16 @@ +import type { WhereUnion } from '../../../types/index.js'; + +export function convertLogical(children: WhereUnion[], operator: 'and' | 'or', negate: boolean): WhereUnion { + const childNodes = children.map((child) => child.where); + const parameters = children.flatMap((child) => child.parameters); + + return { + where: { + type: 'logical', + negate, + operator, + childNodes, + }, + parameters, + }; +} diff --git a/packages/data-sql/src/query-converter/modifiers/index.ts b/packages/data-sql/src/query-converter/modifiers/index.ts new file mode 100644 index 0000000000..b11a1ec7aa --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/index.ts @@ -0,0 +1,2 @@ +export * from './filter/index.js'; +export * from './sort.js'; diff --git a/packages/data-sql/src/query-converter/modifiers/modifiers.ts b/packages/data-sql/src/query-converter/modifiers/modifiers.ts new file mode 100644 index 0000000000..e57e29da77 --- /dev/null +++ b/packages/data-sql/src/query-converter/modifiers/modifiers.ts @@ -0,0 +1,41 @@ +import type { AbstractQueryModifiers } from '@directus/data'; +import type { AbstractSqlClauses, AbstractSqlQuery } from '../../types/index.js'; +import { convertFilter, convertSort } from './index.js'; + +export type Result = { + clauses: Pick; + parameters: AbstractSqlQuery['parameters']; +}; + +export const convertModifiers = ( + modifiers: AbstractQueryModifiers | undefined, + collection: string, + idxGenerator: Generator +) => { + const result: Result = { + clauses: {}, + parameters: [], + }; + + if (modifiers?.filter) { + const convertedFilter = convertFilter(modifiers.filter, collection, idxGenerator); + result.clauses.where = convertedFilter.where; + result.parameters.push(...convertedFilter.parameters); + } + + if (modifiers?.limit) { + result.clauses.limit = { type: 'value', parameterIndex: idxGenerator.next().value }; + result.parameters.push(modifiers.limit.value); + } + + if (modifiers?.offset) { + result.clauses.offset = { type: 'value', parameterIndex: idxGenerator.next().value }; + result.parameters.push(modifiers.offset.value); + } + + if (modifiers?.sort) { + result.clauses.order = convertSort(modifiers.sort); + } + + return result; +}; diff --git a/packages/data-sql/src/converter/convert-sort.test.ts b/packages/data-sql/src/query-converter/modifiers/sort.test.ts similarity index 55% rename from packages/data-sql/src/converter/convert-sort.test.ts rename to packages/data-sql/src/query-converter/modifiers/sort.test.ts index 369f842119..61c85f428b 100644 --- a/packages/data-sql/src/converter/convert-sort.test.ts +++ b/packages/data-sql/src/query-converter/modifiers/sort.test.ts @@ -1,52 +1,50 @@ import type { AbstractQueryNodeSort } from '@directus/data'; import { beforeEach, expect, test } from 'vitest'; import { randomIdentifier } from '@directus/random'; -import { convertSort } from './convert-sort.js'; +import { convertSort } from './sort.js'; -let sample: { - sort: AbstractQueryNodeSort[]; -}; +let sample: AbstractQueryNodeSort[]; beforeEach(() => { - sample = { - sort: [ - { - type: 'sort', - direction: 'ascending', - target: { - type: 'primitive', - field: randomIdentifier(), - }, + sample = [ + { + type: 'sort', + direction: 'ascending', + target: { + type: 'primitive', + field: randomIdentifier(), }, - ], - }; + }, + ]; }); test('convert ascending sort with a single field', () => { - const res = convertSort(sample.sort); + const res = convertSort(sample); expect(res).toStrictEqual([ { - orderBy: sample.sort[0]!.target, + type: 'order', + orderBy: sample[0]!.target, direction: 'ASC', }, ]); }); test('convert descending sort with a single field', () => { - sample.sort[0]!.direction = 'descending'; - const res = convertSort(sample.sort); + sample[0]!.direction = 'descending'; + const res = convertSort(sample); expect(res).toStrictEqual([ { - orderBy: sample.sort[0]!.target, + type: 'order', + orderBy: sample[0]!.target, direction: 'DESC', }, ]); }); test('convert ascending sort with multiple fields', () => { - sample.sort.push({ + sample.push({ type: 'sort', direction: 'ascending', target: { @@ -55,15 +53,17 @@ test('convert ascending sort with multiple fields', () => { }, }); - const res = convertSort(sample.sort); + const res = convertSort(sample); expect(res).toStrictEqual([ { - orderBy: sample.sort[0]!.target, + type: 'order', + orderBy: sample[0]!.target, direction: 'ASC', }, { - orderBy: sample.sort[1]!.target, + type: 'order', + orderBy: sample[1]!.target, direction: 'ASC', }, ]); diff --git a/packages/data-sql/src/converter/convert-sort.ts b/packages/data-sql/src/query-converter/modifiers/sort.ts similarity index 82% rename from packages/data-sql/src/converter/convert-sort.ts rename to packages/data-sql/src/query-converter/modifiers/sort.ts index 7f5836b61f..c027ac077c 100644 --- a/packages/data-sql/src/converter/convert-sort.ts +++ b/packages/data-sql/src/query-converter/modifiers/sort.ts @@ -1,5 +1,5 @@ import type { AbstractQueryNodeSort } from '@directus/data'; -import type { AbstractSqlQueryOrderNode } from '../types.js'; +import type { AbstractSqlQueryOrderNode } from '../../types/index.js'; /** * @param abstractSorts @@ -8,6 +8,7 @@ import type { AbstractSqlQueryOrderNode } from '../types.js'; export const convertSort = (abstractSorts: AbstractQueryNodeSort[]): AbstractSqlQueryOrderNode[] => { return abstractSorts.map((abstractSort) => { return { + type: 'order', orderBy: abstractSort.target, direction: abstractSort.direction === 'descending' ? 'DESC' : 'ASC', }; diff --git a/packages/data-sql/src/utils/param-index-generator.test.ts b/packages/data-sql/src/query-converter/param-index-generator.test.ts similarity index 100% rename from packages/data-sql/src/utils/param-index-generator.test.ts rename to packages/data-sql/src/query-converter/param-index-generator.test.ts diff --git a/packages/data-sql/src/utils/param-index-generator.ts b/packages/data-sql/src/query-converter/param-index-generator.ts similarity index 59% rename from packages/data-sql/src/utils/param-index-generator.ts rename to packages/data-sql/src/query-converter/param-index-generator.ts index 9f1b01ced8..8d2b6edef1 100644 --- a/packages/data-sql/src/utils/param-index-generator.ts +++ b/packages/data-sql/src/query-converter/param-index-generator.ts @@ -1,7 +1,7 @@ /** * Generator function to generate parameter indices. */ -export function* parameterIndexGenerator(): Generator { +export function* parameterIndexGenerator(): Generator { let index = 0; while (true) { diff --git a/packages/data-sql/src/types.ts b/packages/data-sql/src/types.ts deleted file mode 100644 index 74cf3cdce2..0000000000 --- a/packages/data-sql/src/types.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { AbstractQueryNodeSortTargets } from '@directus/data'; - -interface SqlStatementColumn { - type: 'primitive'; - table: string; - column: string; -} -export interface SqlStatementSelectColumn extends SqlStatementColumn { - as?: string; -} - -// export interface SqlStatementSelectFn { -// type: 'fn'; -// fn: string; -// args: (string | number | boolean)[]; -// table: string; -// column: string; -// as?: string; -// } - -// export interface SqlStatementSelectJson { -// type: 'json'; -// table: string; -// column: string; -// as?: string; -// path: string; -// } - -/** - * Used for parameterized queries. - */ -type ParameterIndex = { - /** Indicates where the actual value is stored in the parameter array */ - parameterIndex: number; -}; - -/** - * This is an abstract SQL query which can be passen to all SQL drivers. - * - * @example - * ```ts - * const query: SqlStatement = { - * select: [id], - * from: 'articles', - * limit: 0, - * parameters: [25], - * }; - * ``` - */ -export interface AbstractSqlQuery { - select: SqlStatementSelectColumn[]; - from: string; - limit?: ParameterIndex; - offset?: ParameterIndex; - order?: AbstractSqlQueryOrderNode[]; - where?: AbstractSqlQueryWhereConditionNode | AbstractSqlQueryWhereLogicalNode; - intersect?: AbstractSqlQuery; - parameters: (string | boolean | number)[]; -} - -export type AbstractSqlQueryOrderNode = { - orderBy: AbstractQueryNodeSortTargets; - direction: 'ASC' | 'DESC'; -}; - -/** - * An abstract WHERE clause. - */ -export interface AbstractSqlQueryWhereConditionNode { - type: 'condition'; - - /* value which will be compared to another value or expression. Functions will be supported soon. */ - target: SqlStatementColumn; - - /* an abstract comparator */ - operation: 'eq' | 'lt' | 'lte' | 'gt' | 'gte' | 'in' | 'contains' | 'starts_with' | 'ends_with' | 'intersects'; - - /* indicated of the condition should be negated using NOT */ - negate: boolean; - - /* a value to which the target will be compared */ - compareTo: CompareValueNode; -} - -export interface AbstractSqlQueryWhereLogicalNode { - type: 'logical'; - operator: 'and' | 'or'; - negate: boolean; - childNodes: (AbstractSqlQueryWhereConditionNode | AbstractSqlQueryWhereLogicalNode)[]; -} - -export interface CompareValueNode { - type: 'value'; - parameterIndexes: number[]; -} - -/** - * An actual vendor specific SQL statement with its parameters. - * @example - * ``` - * { - * statement: 'SELECT * FROM "articles" WHERE "articles"."id" = $1;', - * values: [99], - * } - * ``` - */ -export interface ParameterizedSQLStatement { - statement: string; - parameters: (string | number | boolean)[]; -} diff --git a/packages/data-sql/src/types/clauses/index.ts b/packages/data-sql/src/types/clauses/index.ts new file mode 100644 index 0000000000..7f1b06a594 --- /dev/null +++ b/packages/data-sql/src/types/clauses/index.ts @@ -0,0 +1,29 @@ +import type { ParameterTypes, ValueNode } from '../parameterized-statement.js'; +import type { AbstractSqlQueryJoinNode } from './joins/join.js'; +import type { AbstractSqlQueryOrderNode } from './order.js'; +import type { AbstractSqlQueryFnNode } from './selects/fn.js'; +import type { AbstractSqlQuerySelectNode } from './selects/primitive.js'; +import type { AbstractSqlQueryConditionNode, AbstractSqlQueryLogicalNode } from './where/index.js'; + +export interface AbstractSqlClauses { + select: (AbstractSqlQuerySelectNode | AbstractSqlQueryFnNode)[]; + from: string; + joins?: AbstractSqlQueryJoinNode[]; + where?: AbstractSqlQueryWhereNode; + limit?: ValueNode; + offset?: ValueNode; + order?: AbstractSqlQueryOrderNode[]; +} + +export type AbstractSqlQueryWhereNode = AbstractSqlQueryConditionNode | AbstractSqlQueryLogicalNode; + +export type WhereUnion = { + where: AbstractSqlQueryWhereNode; + parameters: ParameterTypes[]; +}; + +export * from './selects/fn.js'; +export * from './selects/primitive.js'; +export * from './joins/join.js'; +export * from './where/index.js'; +export * from './order.js'; diff --git a/packages/data-sql/src/types/clauses/joins/join.ts b/packages/data-sql/src/types/clauses/joins/join.ts new file mode 100644 index 0000000000..c743eeb6ec --- /dev/null +++ b/packages/data-sql/src/types/clauses/joins/join.ts @@ -0,0 +1,25 @@ +import type { AbstractSqlQueryLogicalNode, AbstractSqlQueryConditionNode } from '../where/index.js'; + +/** + * Used to join another table, regardless of the type of relation. + */ +export interface AbstractSqlQueryJoinNode { + type: 'join'; + + /* the foreign table to join */ + table: string; + + /* + * the condition used to specify the relation between the two tables. + * Typically foreignKey = primaryKey or vice versa. Other conditions are possible but not recommended! + * The usage of the existing filter types below is only for the ease of use. + * We reuse the filter logic to specify the join condition, and by that enable the user to specify all kinds of join conditions. + */ + on: AbstractSqlQueryConditionNode | AbstractSqlQueryLogicalNode; + + /* the generated alias which will be part of the actual query */ + as: string; + + /* an alias provided by the user */ + alias?: string; +} diff --git a/packages/data-sql/src/types/clauses/order.ts b/packages/data-sql/src/types/clauses/order.ts new file mode 100644 index 0000000000..83361b4622 --- /dev/null +++ b/packages/data-sql/src/types/clauses/order.ts @@ -0,0 +1,7 @@ +import type { AbstractQueryNodeSortTargets } from '@directus/data'; + +export interface AbstractSqlQueryOrderNode { + type: 'order'; + orderBy: AbstractQueryNodeSortTargets; + direction: 'ASC' | 'DESC'; +} diff --git a/packages/data-sql/src/types/clauses/selects/fn.ts b/packages/data-sql/src/types/clauses/selects/fn.ts new file mode 100644 index 0000000000..278c1d6523 --- /dev/null +++ b/packages/data-sql/src/types/clauses/selects/fn.ts @@ -0,0 +1,27 @@ +import type { AbstractSqlQueryColumn } from './primitive.js'; +import type { ValuesNode } from '../../parameterized-statement.js'; +import type { ExtractFn, ArrayFn } from '@directus/data'; + +/** + * Used to apply a function to a column. + * Currently we support various EXTRACT functions to extract specific parts out of a data/time value. + */ +export interface AbstractSqlQueryFnNode extends AbstractSqlQueryColumn { + type: 'fn'; + + /** + * A list of supported functions. Those are the same as the abstract query. + */ + fn: ExtractFn | ArrayFn; + + /* + * Used to specify additional arguments. + * Same as will all user input, the arguments are passed via parameters. + */ + arguments?: ValuesNode; + + /* This can only be applied when using the function it within the SELECT clause */ + as?: string; + + alias?: string; +} diff --git a/packages/data-sql/src/types/clauses/selects/json.ts b/packages/data-sql/src/types/clauses/selects/json.ts new file mode 100644 index 0000000000..54f7465634 --- /dev/null +++ b/packages/data-sql/src/types/clauses/selects/json.ts @@ -0,0 +1,8 @@ +/** @TODO */ +// export interface SqlStatementSelectJson { +// type: 'json'; +// table: string; +// column: string; +// as?: string; +// path: string; +// } diff --git a/packages/data-sql/src/types/clauses/selects/primitive.ts b/packages/data-sql/src/types/clauses/selects/primitive.ts new file mode 100644 index 0000000000..ed7485bc62 --- /dev/null +++ b/packages/data-sql/src/types/clauses/selects/primitive.ts @@ -0,0 +1,22 @@ +export interface AbstractSqlQueryColumn { + table: string; + column: string; +} + +/** + * Used to select a specific column from a table. + */ +export interface AbstractSqlQuerySelectNode extends AbstractSqlQueryColumn { + type: 'primitive'; + + /* + * A random value used as an temporarily alias to query the databases. + * If a function is applied, than the tmp alias will be set to that node. + */ + as?: string; + + /** + * The final alias optionally provided by the user which will be returned within the response. + */ + alias?: string; +} diff --git a/packages/data-sql/src/types/clauses/where/conditions/field-condition.ts b/packages/data-sql/src/types/clauses/where/conditions/field-condition.ts new file mode 100644 index 0000000000..9590e4cc20 --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/conditions/field-condition.ts @@ -0,0 +1,12 @@ +import type { AbstractSqlQuerySelectNode } from '../../selects/primitive.js'; + +/** + * Condition to filter rows where two columns of different tables are equal. + * Mainly used for JOINs. + */ +export interface SqlConditionFieldNode { + type: 'condition-field'; + operation: 'eq'; + target: AbstractSqlQuerySelectNode; + compareTo: AbstractSqlQuerySelectNode; +} diff --git a/packages/data-sql/src/types/clauses/where/conditions/geo-condition.ts b/packages/data-sql/src/types/clauses/where/conditions/geo-condition.ts new file mode 100644 index 0000000000..fdbf2364dd --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/conditions/geo-condition.ts @@ -0,0 +1,28 @@ +import type { ValueNode } from '../../../parameterized-statement.js'; +import type { AbstractSqlQuerySelectNode } from '../../selects/primitive.js'; + +/** + * Used to retrieve a set of data, where the column in question stores a geographic value which intersects with another given geographic value. + * Here, the two types `condition-geo` and `condition-geo-bbox` from @directus/data are combined into one type, + * because the compare value is the same for both types - it's the reference to the actual value stored in the list of parameters. + * That also why the operations got merged together. + */ +export interface SqlConditionGeoNode { + type: 'condition-geo'; + + /* The column in question */ + target: AbstractSqlQuerySelectNode; + + /** + * The operation to apply. Get only those rows where the targeting column + * - `intersects`: intersects with the given geo value + * - `intersects_bbox`: intersects with a given bounding box + */ + operation: 'intersects' | 'intersects_bbox'; + + /* + * The geo value to compare the value of the column with. + * This needs to be a reference to a geojson object stored in the list of parameters. + */ + compareTo: ValueNode; +} diff --git a/packages/data-sql/src/types/clauses/where/conditions/index.ts b/packages/data-sql/src/types/clauses/where/conditions/index.ts new file mode 100644 index 0000000000..008c82c097 --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/conditions/index.ts @@ -0,0 +1,34 @@ +import type { SqlConditionFieldNode } from './field-condition.js'; +import type { SqlConditionGeoNode } from './geo-condition.js'; +import type { SqlConditionNumberNode } from './number-condition.js'; +import type { SqlConditionSetNode } from './set-condition.js'; +import type { SqlConditionStringNode } from './string-condition.js'; + +/** + * Condition to filter rows. + * Various condition types are supported, each depending on a specific datatype. + * The condition can also be negated on this level. + */ +export interface AbstractSqlQueryConditionNode { + type: 'condition'; + condition: + | SqlConditionStringNode + | SqlConditionNumberNode + | SqlConditionGeoNode + | SqlConditionSetNode + | SqlConditionFieldNode; + negate: boolean; +} + +export type SqlConditionType = + | 'condition-string' + | 'condition-number' + | 'condition-geo' + | 'condition-set' + | 'condition-field'; + +export * from './field-condition.js'; +export * from './geo-condition.js'; +export * from './number-condition.js'; +export * from './string-condition.js'; +export * from './set-condition.js'; diff --git a/packages/data-sql/src/types/clauses/where/conditions/number-condition.ts b/packages/data-sql/src/types/clauses/where/conditions/number-condition.ts new file mode 100644 index 0000000000..c88306701b --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/conditions/number-condition.ts @@ -0,0 +1,19 @@ +import type { AbstractSqlQueryFnNode } from '../../selects/fn.js'; +import type { ValueNode } from '../../../parameterized-statement.js'; +import type { AbstractSqlQuerySelectNode } from '../../selects/primitive.js'; + +/** + * Filter rows where a numeric column is equal, greater than, less than, etc. other given number. + */ +export interface SqlConditionNumberNode { + type: 'condition-number'; + + /* The column in question. Optionally a function can be applied. */ + target: AbstractSqlQuerySelectNode | AbstractSqlQueryFnNode; + + /* The operator valid for a comparison against numbers. */ + operation: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'; + + /* The number to compare the column value with. Specifies a reference to the list of parameters. */ + compareTo: ValueNode; +} diff --git a/packages/data-sql/src/types/clauses/where/conditions/set-condition.ts b/packages/data-sql/src/types/clauses/where/conditions/set-condition.ts new file mode 100644 index 0000000000..7758b70d29 --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/conditions/set-condition.ts @@ -0,0 +1,22 @@ +import type { ValuesNode } from '../../../parameterized-statement.js'; +import type { AbstractSqlQuerySelectNode } from '../../selects/primitive.js'; + +/* + * Condition to filter rows where a column value is in a list of values. + * The value can basically be of any time, although the type should be obviously the same as the column type. + */ +export interface SqlConditionSetNode { + type: 'condition-set'; + + /* The only operator which is valid for a comparison against a set of values. */ + operation: 'in'; + + /* The column in question. */ + target: AbstractSqlQuerySelectNode; + + /* + * Reference to the list of values to compare the column value with. + * The reference is a single value which in turn is a list. + */ + compareTo: ValuesNode; +} diff --git a/packages/data-sql/src/types/clauses/where/conditions/string-condition.ts b/packages/data-sql/src/types/clauses/where/conditions/string-condition.ts new file mode 100644 index 0000000000..6f8a16b656 --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/conditions/string-condition.ts @@ -0,0 +1,21 @@ +import type { ValueNode } from '../../../parameterized-statement.js'; +import type { AbstractSqlQuerySelectNode } from '../../selects/primitive.js'; + +/** + * Condition to filter rows where a string column value contains, starts with, ends with, or is equal to another given string. + */ +export interface SqlConditionStringNode { + type: 'condition-string'; + + /* The column in question. */ + target: AbstractSqlQuerySelectNode; + + /* The valid operators for a comparison against strings. */ + operation: 'contains' | 'starts_with' | 'ends_with' | 'eq'; + + /* + * The string to compare the column value with. + * Specifies a reference to the list of parameters. + */ + compareTo: ValueNode; +} diff --git a/packages/data-sql/src/types/clauses/where/index.ts b/packages/data-sql/src/types/clauses/where/index.ts new file mode 100644 index 0000000000..6f2bb9ff20 --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/index.ts @@ -0,0 +1,2 @@ +export * from './conditions/index.js'; +export * from './logical.js'; diff --git a/packages/data-sql/src/types/clauses/where/logical.ts b/packages/data-sql/src/types/clauses/where/logical.ts new file mode 100644 index 0000000000..30ddf3d2a4 --- /dev/null +++ b/packages/data-sql/src/types/clauses/where/logical.ts @@ -0,0 +1,17 @@ +import type { AbstractSqlQueryConditionNode } from './conditions/index.js'; + +/** + * A wrapper to add multiple conditions at once. + */ +export interface AbstractSqlQueryLogicalNode { + type: 'logical'; + + /* The logical operator to use to group the conditions. */ + operator: 'and' | 'or'; + + /* Specifies of the condition should be negated or not. */ + negate: boolean; + + /* A list of conditions or a nested group with another operator. */ + childNodes: (AbstractSqlQueryConditionNode | AbstractSqlQueryLogicalNode)[]; +} diff --git a/packages/data-sql/src/types/index.ts b/packages/data-sql/src/types/index.ts new file mode 100644 index 0000000000..4de5ed12ac --- /dev/null +++ b/packages/data-sql/src/types/index.ts @@ -0,0 +1,40 @@ +/** + * A set of types which form the abstract SQL query. + * It's still neutral to concrete SQL dialects and databases but provides to SQL drivers with a query type that they can more easy work with. + * + * How the abstract SQL query types differ from the abstract query. + * - In the abstract query the user input values are put directly within the query directly. + * The abstract SQL however stores the user input values in a list of parameters, so that the SQL driver always perform parameterized queries. + * That way we prevent SQL injection. + * Moving the user input values into a list of parameters and replace the input value with the index of the value from the list, is a big part of the converter. + * - Instead of a wrapper for negation, here the negation is a property on the type. + * So the abstract SQL does not have a node of type 'negate' but instead the nodes have a property called 'negate'. + * + * @module + */ +import type { ParameterTypes } from './parameterized-statement.js'; +import type { AbstractSqlClauses } from './clauses/index.js'; + +/** + * This is an abstract SQL query which can be passed to all SQL drivers. + * + * @example + * The following query gets the title of all articles and limits the result to 25 rows. + * ```ts + * const query: SqlStatement = { + * select: [title], + * from: 'articles', + * limit: 0, // this is the index of the parameter + * parameters: [25], + * }; + * ``` + */ + +export interface AbstractSqlQuery { + clauses: AbstractSqlClauses; + parameters: ParameterTypes[]; + aliasMapping: Map; +} + +export * from './clauses/index.js'; +export * from './parameterized-statement.js'; diff --git a/packages/data-sql/src/types/parameterized-statement.ts b/packages/data-sql/src/types/parameterized-statement.ts new file mode 100644 index 0000000000..715f99c46b --- /dev/null +++ b/packages/data-sql/src/types/parameterized-statement.ts @@ -0,0 +1,34 @@ +import type { GeoJSONGeometry } from 'wellknown'; + +/** + * Used pass a single value. + */ +export interface ValueNode { + type: 'value'; + parameterIndex: number; +} + +/** + * Used pass an arbitrary amount of values. + */ +export interface ValuesNode { + type: 'values'; + parameterIndexes: number[]; +} + +/** + * An actual vendor specific SQL statement with its parameters. + * @example + * ``` + * { + * statement: 'SELECT * FROM "articles" WHERE "articles"."id" = $1;', + * values: [99], + * } + * ``` + */ +export interface ParameterizedSqlStatement { + statement: string; + parameters: ParameterTypes[]; +} + +export type ParameterTypes = string | boolean | number | GeoJSONGeometry; diff --git a/packages/data-sql/src/utils/index.ts b/packages/data-sql/src/utils/index.ts new file mode 100644 index 0000000000..7641b8354e --- /dev/null +++ b/packages/data-sql/src/utils/index.ts @@ -0,0 +1 @@ +export * from './numeric-operator-conversion.js'; diff --git a/packages/data-sql/src/utils/numeric-operator-conversion.ts b/packages/data-sql/src/utils/numeric-operator-conversion.ts new file mode 100644 index 0000000000..748621da03 --- /dev/null +++ b/packages/data-sql/src/utils/numeric-operator-conversion.ts @@ -0,0 +1,30 @@ +/** + * @param operation + * @param negate + * @returns + */ +export function convertNumericOperators(operation: string, negate: boolean) { + let result = ''; + + switch (operation) { + case 'eq': + result = `${negate ? '!=' : '='}`; + break; + case 'gt': + result = `${negate ? '<=' : '>'}`; + break; + case 'gte': + result = `${negate ? '<' : '>='}`; + break; + case 'lt': + result = `${negate ? '>=' : '<'}`; + break; + case 'lte': + result = `${negate ? '>' : '<='}`; + break; + default: + throw new Error(`Unknown numeric operator: ${operation}`); + } + + return result; +} diff --git a/packages/data/.dependency-cruiser.cjs b/packages/data/.dependency-cruiser.cjs new file mode 100644 index 0000000000..826360f8b6 --- /dev/null +++ b/packages/data/.dependency-cruiser.cjs @@ -0,0 +1,435 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: 'no-circular', + severity: 'warn', + comment: + 'This dependency is part of a circular relationship. You might want to revise ' + + 'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', + from: {}, + to: { + circular: true, + }, + }, + { + name: 'no-orphans', + comment: + "This is an orphan module - it's likely not used (anymore?). Either use it or " + + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " + + 'add an exception for it in your dependency-cruiser configuration. By default ' + + 'this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration ' + + 'files (.d.ts), tsconfig.json and some of the babel and webpack configs.', + severity: 'warn', + from: { + orphan: true, + pathNot: [ + '(^|/).[^/]+.(js|cjs|mjs|ts|json)$', // dot files + '.d.ts$', // TypeScript declaration files + '(^|/)tsconfig.json$', // TypeScript config + '(^|/)(babel|webpack).config.(js|cjs|mjs|ts|json)$', // other configs + ], + }, + to: {}, + }, + { + name: 'no-deprecated-core', + comment: + 'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + + "bound to exist - node doesn't deprecate lightly.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['core'], + path: [ + '^(v8/tools/codemap)$', + '^(v8/tools/consarray)$', + '^(v8/tools/csvparser)$', + '^(v8/tools/logreader)$', + '^(v8/tools/profile_view)$', + '^(v8/tools/profile)$', + '^(v8/tools/SourceMap)$', + '^(v8/tools/splaytree)$', + '^(v8/tools/tickprocessor-driver)$', + '^(v8/tools/tickprocessor)$', + '^(node-inspect/lib/_inspect)$', + '^(node-inspect/lib/internal/inspect_client)$', + '^(node-inspect/lib/internal/inspect_repl)$', + '^(async_hooks)$', + '^(punycode)$', + '^(domain)$', + '^(constants)$', + '^(sys)$', + '^(_linklist)$', + '^(_stream_wrap)$', + ], + }, + }, + { + name: 'not-to-deprecated', + comment: + 'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + + 'version of that module, or find an alternative. Deprecated modules are a security risk.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['deprecated'], + }, + }, + { + name: 'no-non-package-json', + severity: 'error', + comment: + "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + + "That's problematic as the package either (1) won't be available on live (2 - worse) will be " + + 'available on live with an non-guaranteed version. Fix it by adding the package to the dependencies ' + + 'in your package.json.', + from: {}, + to: { + dependencyTypes: ['npm-no-pkg', 'npm-unknown'], + }, + }, + { + name: 'not-to-unresolvable', + comment: + "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + + 'module: add it to your package.json. In all other cases you likely already know what to do.', + severity: 'error', + from: {}, + to: { + couldNotResolve: true, + }, + }, + { + name: 'no-duplicate-dep-types', + comment: + "Likely this module depends on an external ('npm') package that occurs more than once " + + 'in your package.json i.e. bot as a devDependencies and in dependencies. This will cause ' + + 'maintenance problems later on.', + severity: 'warn', + from: {}, + to: { + moreThanOneDependencyType: true, + // as it's pretty common to have a type import be a type only import + // _and_ (e.g.) a devDependency - don't consider type-only dependency + // types for this rule + dependencyTypesNot: ['type-only'], + }, + }, + + /* rules you might want to tweak for your specific situation: */ + + { + name: 'not-to-spec', + comment: + 'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + + "If there's something in a spec that's of use to other modules, it doesn't have that single " + + 'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', + severity: 'error', + from: {}, + to: { + path: '.(spec|test).(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee.md)$', + }, + }, + { + name: 'not-to-dev-dep', + severity: 'error', + comment: + "This module depends on an npm package from the 'devDependencies' section of your " + + 'package.json. It looks like something that ships to production, though. To prevent problems ' + + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + + 'section of your package.json. If this module is development only - add it to the ' + + 'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', + from: { + path: '^(src)', + pathNot: '.(spec|test).(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee.md)$', + }, + to: { + dependencyTypes: ['npm-dev'], + }, + }, + { + name: 'optional-deps-used', + severity: 'info', + comment: + 'This module depends on an npm package that is declared as an optional dependency ' + + "in your package.json. As this makes sense in limited situations only, it's flagged here. " + + "If you're using an optional dependency here by design - add an exception to your" + + 'dependency-cruiser configuration.', + from: {}, + to: { + dependencyTypes: ['npm-optional'], + }, + }, + { + name: 'peer-deps-used', + comment: + 'This module depends on an npm package that is declared as a peer dependency ' + + 'in your package.json. This makes sense if your package is e.g. a plugin, but in ' + + 'other cases - maybe not so much. If the use of a peer dependency is intentional ' + + 'add an exception to your dependency-cruiser configuration.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: ['npm-peer'], + }, + }, + ], + options: { + /* conditions specifying which files not to follow further when encountered: + - path: a regular expression to match + - dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/main/doc/rules-reference.md#dependencytypes-and-dependencytypesnot + for a complete list + */ + doNotFollow: { + path: 'node_modules', + }, + + /* conditions specifying which dependencies to exclude + - path: a regular expression to match + - dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies. + leave out if you want to exclude neither (recommended!) + */ + // exclude : { + // path: '', + // dynamic: true + // }, + + /* pattern specifying which files to include (regular expression) + dependency-cruiser will skip everything not matching this pattern + */ + // includeOnly : '', + + /* dependency-cruiser will include modules matching against the focus + regular expression in its output, as well as their neighbours (direct + dependencies and dependents) + */ + // focus : '', + + /* list of module systems to cruise */ + // moduleSystems: ['amd', 'cjs', 'es6', 'tsd'], + + /* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/' + to open it on your online repo or `vscode://file/${process.cwd()}/` to + open it in visual studio code), + */ + // prefix: '', + + /* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation + true: also detect dependencies that only exist before typescript-to-javascript compilation + "specify": for each dependency identify whether it only exists before compilation or also after + */ + tsPreCompilationDeps: true, + + /* + list of extensions to scan that aren't javascript or compile-to-javascript. + Empty by default. Only put extensions in here that you want to take into + account that are _not_ parsable. + */ + // extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"], + + /* if true combines the package.jsons found from the module up to the base + folder the cruise is initiated from. Useful for how (some) mono-repos + manage dependencies & dependency definitions. + */ + // combinedDependencies: false, + + /* if true leave symlinks untouched, otherwise use the realpath */ + // preserveSymlinks: false, + + /* TypeScript project file ('tsconfig.json') to use for + (1) compilation and + (2) resolution (e.g. with the paths property) + + The (optional) fileName attribute specifies which file to take (relative to + dependency-cruiser's current working directory). When not provided + defaults to './tsconfig.json'. + */ + tsConfig: { + fileName: 'tsconfig.json', + }, + + /* Webpack configuration to use to get resolve options from. + + The (optional) fileName attribute specifies which file to take (relative + to dependency-cruiser's current working directory. When not provided defaults + to './webpack.conf.js'. + + The (optional) `env` and `arguments` attributes contain the parameters to be passed if + your webpack config is a function and takes them (see webpack documentation + for details) + */ + // webpackConfig: { + // fileName: 'webpack.config.js', + // env: {}, + // arguments: {} + // }, + + /* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use + for compilation (and whatever other naughty things babel plugins do to + source code). This feature is well tested and usable, but might change + behavior a bit over time (e.g. more precise results for used module + systems) without dependency-cruiser getting a major version bump. + */ + // babelConfig: { + // fileName: '.babelrc', + // }, + + /* List of strings you have in use in addition to cjs/ es6 requires + & imports to declare module dependencies. Use this e.g. if you've + re-declared require, use a require-wrapper or use window.require as + a hack. + */ + // exoticRequireStrings: [], + /* options to pass on to enhanced-resolve, the package dependency-cruiser + uses to resolve module references to disk. You can set most of these + options in a webpack.conf.js - this section is here for those + projects that don't have a separate webpack config file. + + Note: settings in webpack.conf.js override the ones specified here. + */ + enhancedResolveOptions: { + /* List of strings to consider as 'exports' fields in package.json. Use + ['exports'] when you use packages that use such a field and your environment + supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack). + + If you have an `exportsFields` attribute in your webpack config, that one + will have precedence over the one specified here. + */ + exportsFields: ['exports'], + /* List of conditions to check for in the exports field. e.g. use ['imports'] + if you're only interested in exposed es6 modules, ['require'] for commonjs, + or all conditions at once `(['import', 'require', 'node', 'default']`) + if anything goes for you. Only works when the 'exportsFields' array is + non-empty. + + If you have a 'conditionNames' attribute in your webpack config, that one will + have precedence over the one specified here. + */ + conditionNames: ['import', 'require', 'node', 'default'], + /* + The extensions, by default are the same as the ones dependency-cruiser + can access (run `npx depcruise --info` to see which ones that are in + _your_ environment. If that list is larger than what you need (e.g. + it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use + TypeScript you can pass just the extensions you actually use (e.g. + [".js", ".jsx"]). This can speed up the most expensive step in + dependency cruising (module resolution) quite a bit. + */ + // extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"], + /* + If your TypeScript project makes use of types specified in 'types' + fields in package.jsons of external dependencies, specify "types" + in addition to "main" in here, so enhanced-resolve (the resolver + dependency-cruiser uses) knows to also look there. You can also do + this if you're not sure, but still use TypeScript. In a future version + of dependency-cruiser this will likely become the default. + */ + mainFields: ['main', 'types'], + }, + reporterOptions: { + dot: { + /* pattern of modules that can be consolidated in the detailed + graphical dependency graph. The default pattern in this configuration + collapses everything in node_modules to one folder deep so you see + the external modules, but not the innards your app depends upon. + */ + collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + don't worry - dependency-cruiser will fall back to the default one. + */ + // theme: { + // graph: { + // /* use splines: "ortho" for straight lines. Be aware though + // graphviz might take a long time calculating ortho(gonal) + // routings. + // */ + // splines: "true" + // }, + // modules: [ + // { + // criteria: { matchesFocus: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesFocus: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { matchesReaches: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesReaches: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { source: "^src/model" }, + // attributes: { fillcolor: "#ccccff" } + // }, + // { + // criteria: { source: "^src/view" }, + // attributes: { fillcolor: "#ccffcc" } + // }, + // ], + // dependencies: [ + // { + // criteria: { "rules[0].severity": "error" }, + // attributes: { fontcolor: "red", color: "red" } + // }, + // { + // criteria: { "rules[0].severity": "warn" }, + // attributes: { fontcolor: "orange", color: "orange" } + // }, + // { + // criteria: { "rules[0].severity": "info" }, + // attributes: { fontcolor: "blue", color: "blue" } + // }, + // { + // criteria: { resolved: "^src/model" }, + // attributes: { color: "#0000ff77" } + // }, + // { + // criteria: { resolved: "^src/view" }, + // attributes: { color: "#00770077" } + // } + // ] + // } + }, + archi: { + /* pattern of modules that can be consolidated in the high level + graphical dependency graph. If you use the high level graphical + dependency graph reporter (`archi`) you probably want to tweak + this collapsePattern to your situation. + */ + collapsePattern: '^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + for 'archi' dependency-cruiser will use the one specified in the + dot section (see above), if any, and otherwise use the default one. + */ + // theme: { + // }, + }, + text: { + highlightFocused: true, + }, + }, + }, +}; +// generated: dependency-cruiser@13.1.3 on 2023-08-18T08:17:55.872Z diff --git a/packages/data/package.json b/packages/data/package.json index 51aec8bfdb..4034b8177b 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -23,13 +23,16 @@ "scripts": { "build": "tsup src/index.ts --format=esm --dts", "dev": "tsup src/index.ts --format=esm --dts --watch", - "test": "vitest --watch=false" + "test": "vitest --watch=false", + "depcruise": "depcruise src --include-only '^src' -x test.ts --output-type dot | dot -T svg > dependency-graph.svg" }, "devDependencies": { "@directus/random": "workspace:*", "@directus/tsconfig": "workspace:*", "@types/node": "18.16.12", + "@types/wellknown": "0.5.4", "@vitest/coverage-c8": "0.31.1", + "dependency-cruiser": "13.1.4", "tsup": "7.2.0", "typescript": "5.2.2", "vitest": "0.31.1" diff --git a/packages/data/readme.md b/packages/data/readme.md index 39bc53361d..20bd9a6a79 100644 --- a/packages/data/readme.md +++ b/packages/data/readme.md @@ -8,52 +8,37 @@ npm install @directus/data ``` +The installation of a driver is also required. + ## Usage -Create a new manager: - -```js -import { DataEngine } from '@directus/data'; - -const data = new DataEngine(); -``` - -Register drivers: +### Setup ```js import { DataEngine } from '@directus/data'; import { DataDriverPostgres } from '@directus/data-driver-postgres'; -const data = new DataEngine(); +// Instantiate the engine +const engine = new DataEngine(); -await data.registerStore( - 'postgres', - new DataDriverPostgres({ - connectionString: 'postgresql://root:password@localhost/mydb', - }) -); +// Instantiate a driver +const pgDriver = new DataDriverPostgres({ + connectionString: 'postgresql://root:password@localhost/mydb', +}); + +// register the driver to the engine +await engine.registerStore('postgres', pgDriver); ``` -Query data: +### Query data ```js -import { DataEngine } from '@directus/data'; -import { DataDriverPostgres } from '@directus/data-driver-postgres'; - -const data = new DataEngine(); - -await data.registerStore( - 'postgres', - new DataDriverPostgres({ - connectionString: 'postgresql://root:password@localhost/mydb', - }) -); - -await data.query({ +// query data +await engine.query({ root: true, store: 'postgres', collection: 'articles', - nodes: [ + fields: [ { type: 'primitive', field: 'id', @@ -62,16 +47,19 @@ await data.query({ }); ``` -## Flow - -This visualizes the general data flow regarding `data`. +The above is resulting in the following flow. ```mermaid -graph LR; +graph TB; api --> data - data --> sql-adapter - data --> no-sql-adapter - sql-adapter ---> db1[(datastore)] - sql-adapter --- data-sql - no-sql-adapter ---> db2[(datastore)] + subgraph da[data abstraction] + direction TB + data --> data-driver-x --> db1[(datastore)] + data --> data-driver-y --> db2[(datastore)] + end ``` + +## Current architecture of this package + +To get an overview of how the package is organized regarding it's files, directories and the dependencies between them, +run `pnpm run depcruise` and have a look in the created `dependency-graph.svg` image. diff --git a/packages/data/src/index.test.ts b/packages/data/src/index.test.ts index 7ca187230b..80f760f7d0 100644 --- a/packages/data/src/index.test.ts +++ b/packages/data/src/index.test.ts @@ -43,7 +43,7 @@ describe('#query', () => { store: sample.mockStoreIdentifier, root: true, collection: randomIdentifier(), - nodes: [], + fields: [], }; await engine.query(query); diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 0f28902baa..74a04c175e 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -1,4 +1,5 @@ -import type { AbstractQuery } from './types/abstract-query.js'; +import type { ReadableStream } from 'node:stream/web'; +import type { AbstractQuery } from './types/abstract-query/abstract-query.js'; import type { DataDriver } from './types/driver.js'; export class DataEngine { @@ -26,7 +27,7 @@ export class DataEngine { } /** Execute a root abstract query */ - async query(query: AbstractQuery): Promise { + async query(query: AbstractQuery): Promise { return this.store(query.store).query(query); } diff --git a/packages/data/src/types/abstract-query.ts b/packages/data/src/types/abstract-query.ts deleted file mode 100644 index 4707862fec..0000000000 --- a/packages/data/src/types/abstract-query.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** The root query to be executed */ -export interface AbstractQuery { - /** Marked as entrypoint of the query */ - root: true; - - /** Location where the data is stored */ - store: string; - - /** Name of the collection entrypoint within the store */ - collection: string; - - /** All fields to select in the query */ - nodes: AbstractQueryFieldNode[]; - - /** Optional attributes to perform a fine granular query */ - modifiers?: AbstractQueryModifiers; -} - -type AbstractQueryNodeType = 'primitive' | 'fn' | 'm2o' | 'o2m' | 'a2o' | 'o2a'; - -/** - * All nodes which can be used within the `nodes` array of the `AbstractQuery` have a type attribute. - * With this in place it can easily be determined how to technically handle this field. - * @see `AbstractQueryNodeType` for all possible types. - */ -interface AbstractQueryNode { - /** the type of the node */ - type: AbstractQueryNodeType; -} - -/** - * A group of all possible field types. - * This can be used within the `nodes` array of the `AbstractQuery`. - */ -export type AbstractQueryFieldNode = - | AbstractQueryFieldNodePrimitive - | AbstractQueryFieldNodeFn - | AbstractQueryFieldNodeRelated; - -/** - * Generic primitive value read from the store field - * @example - * Let's say you want the engine to only return the `id` field of the collection in question: - * For that you would create a node like the following and add it to the `nodes` of the query. - * ``` - * const primitiveField: AbstractQueryFieldNodePrimitive = { - * type: 'primitive', - * field: 'attribute_xy' - * } - * ``` - */ -export interface AbstractQueryFieldNodePrimitive extends AbstractQueryNode { - type: 'primitive'; - - /** the name of the attribute */ - field: string; - - alias?: string; -} - -export type AbstractQueryFn = 'year' | 'month' | 'week' | 'day' | 'weekday' | 'hour' | 'minute' | 'second'; - -/** - * Used to apply a function to a specific field before returning it. - * @example - * There are several functions available. - * Let's say you want to only return the year of a date field: - * ```js - * const functionNode: AbstractQueryFieldNodeFn = { - * type: 'fn', - * fn: 'year', - * targetNode: { - * type: 'primitive', - * field: 'date_created' - * } - * ``` - */ -export interface AbstractQueryFieldNodeFn extends AbstractQueryNode { - type: 'fn'; - - fn: AbstractQueryFn; - - targetNode: AbstractQueryFieldNodePrimitive | AbstractQueryFieldNodeFn; - - args?: (string | number | boolean)[]; - - alias?: string; -} - -/** - * This is a basic interface for all relational field types. - */ -export interface AbstractQueryFieldNodeRelatedBase { - nodes: AbstractQueryFieldNode[]; - - /** Regardless of the type of the relationship, it always possible to add modifiers to the foreign collection to adjust the results. */ - modifiers?: AbstractQueryModifiers; - - alias?: string; -} - -/** - * With those Used to build a relational query for m2o and o2m relations. - */ -export type AbstractQueryFieldNodeRelated = - | AbstractQueryFieldNodeRelatedManyToOne - | AbstractQueryFieldNodeRelatedOneToMany - | AbstractQueryFieldNodeRelatedAnyToOne - | AbstractQueryFieldNodeRelatedOneToAny; - -/** - * Used to build a relational query for m2o and o2m relations. - * @example - * ``` - * const functionNode = { - * current: { - * fields: ['id'] - * }, - * external: { - * store: 'mongodb', - * collection: 'some-collection', - * } - * ``` - */ -interface AbstractQueryFieldNodeRelatedJoinMany { - /** the field of the current collection which has the relational value to an external collection or item */ - current: { - fields: string[]; - }; - - /** the external collection or item which should be pulled/joined/merged into the current collection */ - external: { - store?: string; - collection: string; - fields: string[]; - }; -} - -interface AbstractQueryFieldNodeRelatedJoinAny { - current: { - collectionField: string; - fields: string[]; - }; - - external: { - store?: string; - fields: string[]; - }; -} - -export interface AbstractQueryFieldNodeRelatedManyToOne extends AbstractQueryNode, AbstractQueryFieldNodeRelatedBase { - type: 'm2o'; - - join: AbstractQueryFieldNodeRelatedJoinMany; -} - -export interface AbstractQueryFieldNodeRelatedOneToMany extends AbstractQueryNode, AbstractQueryFieldNodeRelatedBase { - type: 'o2m'; - - join: AbstractQueryFieldNodeRelatedJoinMany; -} - -export interface AbstractQueryFieldNodeRelatedAnyToOne extends AbstractQueryNode, AbstractQueryFieldNodeRelatedBase { - type: 'a2o'; - - join: AbstractQueryFieldNodeRelatedJoinAny; -} - -export interface AbstractQueryFieldNodeRelatedOneToAny extends AbstractQueryNode, AbstractQueryFieldNodeRelatedBase { - type: 'o2a'; - - join: AbstractQueryFieldNodeRelatedJoinAny; -} - -// ======================================== Modifiers ========================================================= - -/** - * Optional attributes to customize the query results - */ -export interface AbstractQueryModifiers { - limit?: AbstractQueryNodeLimit; - offset?: AbstractQueryNodeOffset; - sort?: AbstractQueryNodeSort[]; - filter?: AbstractQueryFilterNode; -} - -interface AbstractQueryModifierNode { - type: 'limit' | 'offset' | 'sort' | 'logical' | 'condition' | 'negate'; -} - -/** - * Specifies the maximum amount of returning results - */ -interface AbstractQueryNodeLimit extends AbstractQueryModifierNode { - type: 'limit'; - value: number; -} - -/** - * Specifies the number of items to skip before returning results - */ -interface AbstractQueryNodeOffset extends AbstractQueryModifierNode { - type: 'offset'; - value: number; -} - -export type AbstractQueryNodeSortTargets = - | AbstractQueryFieldNodePrimitive - | AbstractQueryFieldNodeFn - // TDB when we implement relations: - | AbstractQueryFieldNodeRelatedManyToOne - | AbstractQueryFieldNodeRelatedAnyToOne; - -/** - * Specifies the order of the result, f.e. for a primitive field. - * @example - * ```js - * const sortNode = { - * type: 'sort', - * direction: 'ascending', - * target: { - * type: 'primitive', - * field: 'attribute_xy' - * } - * } - * ``` - * Alternatively a function can be applied a the field. - * The result is then used for sorting. - * @example - * ```js - * const sortNode = { - * type: 'sort', - * direction: 'ascending', - * target: { - * type: 'fn', - * fn: 'year', - * targetNode: { - * type: 'primitive' - * field: 'date_created' - * } - * } - */ -export interface AbstractQueryNodeSort extends AbstractQueryModifierNode { - type: 'sort'; - - /** the desired order */ - direction: 'ascending' | 'descending'; - - /** the node on which the sorting should be applied */ - target: AbstractQueryNodeSortTargets; -} - -/** - * Used to create logical operations. - * @example - * Let's say you want to only return rows where two conditions are true. - * First condition that some field value needs to be qual to a provided value and another condition that one field is less than another provided value. - * This would look like this: - * ``` - * { - * type: 'logical', - * operator: 'and', - * childNodes: [ - * { - * type: 'condition', - * operation: 'eq', - * targetNode: { type: 'field', field: 'a' } - * value: 5 - * }, - * { - * type: 'condition', - * operation: 'lt', - * targetNode: { type: 'field', field: 'b' } - * value: 28 - * } - * ] - * } - * ``` - * It is also possible to nest conditions with the logical operator. - * ``` - * { - * type: 'logical', - * operator: 'and', - * childNodes: [ - * { - * type: 'condition', - * operation: 'eq', - * targetNode: { type: 'field', field: 'a' } - * value: 5 - * }, - * { - * type: 'logical', - * operator: 'and', - * childNodes: [ - * { - * type: 'condition', - * operation: 'eq', - * targetNode: { type: 'field', field: 'b' } - * value: 'something' - * }, - * { - * type: 'condition', - * operation: 'gt', - * targetNode: { type: 'field', field: 'c' } - * value: true - * } - * ], - * } - * ] - * } - * ``` - */ -export type AbstractQueryFilterNode = AbstractQueryNodeLogical | AbstractQueryNodeNegate | AbstractQueryNodeCondition; - -export interface AbstractQueryNodeLogical extends AbstractQueryModifierNode { - type: 'logical'; - - operator: 'and' | 'or'; - - /** the values for the the operation. */ - childNodes: AbstractQueryFilterNode[]; -} - -export interface AbstractQueryNodeNegate extends AbstractQueryModifierNode { - type: 'negate'; - - /** the values for the the operation. */ - childNode: AbstractQueryFilterNode; -} - -/** - * Used to set conditions on a query. The item in question needs to match all conditions to be returned. - * @example - * ``` - * { - * type: 'condition', - * operation: 'lt', - * targetNode: { type: 'field', field: 'b' } - * value: 5 - * } - * ``` - */ -export interface AbstractQueryNodeCondition extends AbstractQueryModifierNode { - type: 'condition'; - - /** the node on which the condition should be applied */ - target: - | AbstractQueryFieldNodePrimitive - | AbstractQueryFieldNodeFn - | AbstractQueryFieldNodeRelatedManyToOne - | AbstractQueryFieldNodeRelatedAnyToOne; - - /** the operation to perform on the target */ - operation: 'eq' | 'lt' | 'gt' | 'contains' | 'starts_with' | 'ends_with' | 'intersects' | 'intersects_bounding_box'; - - compareTo: AbstractQueryNodeConditionValue; -} - -export interface AbstractQueryNodeConditionValue { - type: 'value'; - value: string | number | boolean; -} - -/** - * @TODO - * - Should we support "Distinct", if so where does it live (field level vs collection level) - * - Rethink every / some - */ diff --git a/packages/data/src/types/abstract-query/abstract-query.ts b/packages/data/src/types/abstract-query/abstract-query.ts new file mode 100644 index 0000000000..e1f1613ddc --- /dev/null +++ b/packages/data/src/types/abstract-query/abstract-query.ts @@ -0,0 +1,42 @@ +/** + * The query can be seen as a tree with various nodes. + * Each node has a type and different attributes. + * + * @module abstract-query + */ +import type { AbstractQueryModifiers } from './modifiers/index.js'; +import type { AbstractQueryFieldNode } from './fields/fieldNodes.js'; + +/** + * The abstract root query + */ +export interface AbstractQuery { + /** Marked as entrypoint of the query */ + root: boolean; + + /** Location where the data is stored */ + store: string; + + /** Name of the collection entrypoint within the store */ + collection: string; + + /** All fields to select in the query*/ + fields: AbstractQueryFieldNode[]; + + /** Optional attributes to perform a fine granular query */ + modifiers?: AbstractQueryModifiers; +} + +// disabled for now: it might be redundant +// type AbstractQueryNodeType = 'primitive' | 'fn' | 'm2o' | 'o2m' | 'a2o' | 'o2a'; + +/** + * @TODO + * - Rethink every / some + */ + +export * from './modifiers/index.js'; +export * from './fields/function.js'; +export * from './fields/primitive.js'; +export * from './fields/related.js'; +export * from './fields/fieldNodes.js'; diff --git a/packages/data/src/types/abstract-query/fields/fieldNodes.ts b/packages/data/src/types/abstract-query/fields/fieldNodes.ts new file mode 100644 index 0000000000..84d7805d74 --- /dev/null +++ b/packages/data/src/types/abstract-query/fields/fieldNodes.ts @@ -0,0 +1,8 @@ +import type { AbstractQueryFieldNodePrimitive } from './primitive.js'; +import type { AbstractQueryFieldNodeFn } from './function.js'; +import type { AbstractQueryFieldNodeRelated } from './related.js'; + +export type AbstractQueryFieldNode = + | AbstractQueryFieldNodePrimitive + | AbstractQueryFieldNodeFn + | AbstractQueryFieldNodeRelated; diff --git a/packages/data/src/types/abstract-query/fields/function.ts b/packages/data/src/types/abstract-query/fields/function.ts new file mode 100644 index 0000000000..9ddc49a187 --- /dev/null +++ b/packages/data/src/types/abstract-query/fields/function.ts @@ -0,0 +1,50 @@ +export type SupportedExtractFunctions = 'year' | 'month' | 'week' | 'day' | 'weekday' | 'hour' | 'minute' | 'second'; + +/** + * Used to apply a function to a specific field before returning it. + * @example + * There are several functions available. + * Let's say you want to only return the year of a date field: + * ```js + * { + * type: 'fn', + * fn: 'year', + * field: 'date_created' + * } + * ``` + */ +export interface AbstractQueryFieldNodeFn { + type: 'fn'; + + fn: ExtractFn | ArrayFn; + + field: string; + + /* + * Those are currently not really needed but. + * Both, the extract functions and the count function don't allow arguments. + * However, this functionality is already implemented. + */ + args?: (string | number | boolean)[]; + + alias?: string; +} + +/** + * To extract a specific part of a date/time value. + */ +export interface ExtractFn { + type: 'extractFn'; + fn: SupportedExtractFunctions; + + /* + * Indicated if a column is of type TIMESTAMP. + * It's used to let the database parse the column properly. + */ + isTimestampType?: boolean; +} + +export interface ArrayFn { + type: 'arrayFn'; + fn: 'count'; +} diff --git a/packages/data/src/types/abstract-query/fields/primitive.ts b/packages/data/src/types/abstract-query/fields/primitive.ts new file mode 100644 index 0000000000..09ae7da9a2 --- /dev/null +++ b/packages/data/src/types/abstract-query/fields/primitive.ts @@ -0,0 +1,20 @@ +/** + * Generic primitive value read from the store field + * @example + * Let's say you want the engine to only return the `id` field of the collection in question: + * For that you would create a node like the following and add it to the `nodes` of the query. + * ``` + * const primitiveField: AbstractQueryFieldNodePrimitive = { + * type: 'primitive', + * field: 'attribute_xy' + * } + * ``` + */ +export interface AbstractQueryFieldNodePrimitive { + type: 'primitive'; + + /** the name of the attribute */ + field: string; + + alias?: string; +} diff --git a/packages/data/src/types/abstract-query/fields/related.ts b/packages/data/src/types/abstract-query/fields/related.ts new file mode 100644 index 0000000000..70d84fbd60 --- /dev/null +++ b/packages/data/src/types/abstract-query/fields/related.ts @@ -0,0 +1,110 @@ +/** + * This file will be split up into multiple files soon. + */ +import type { AbstractQueryModifiers } from '../modifiers/index.js'; +import type { AbstractQueryFieldNodePrimitive } from './primitive.js'; +import type { AbstractQueryFieldNodeFn } from './function.js'; + +/** + * This is a basic interface for all relational field types. + */ +export interface AbstractQueryFieldNodeRelatedBase { + /* From the related collection the user can pick primitives, apply a function or add another relational node */ + fields: (AbstractQueryFieldNodePrimitive | AbstractQueryFieldNodeFn | AbstractQueryFieldNodeRelated)[]; + + /** Regardless of the type of the relationship, it always possible to add modifiers to the foreign collection to adjust the results. */ + modifiers?: AbstractQueryModifiers; + + alias?: string; +} + +/** + * Used to build a relational query for m2o and o2m relations. + */ +export type AbstractQueryFieldNodeRelated = + | AbstractQueryFieldNodeRelatedManyToOne + | AbstractQueryFieldNodeRelatedOneToMany + | AbstractQueryFieldNodeRelatedAnyToOne + | AbstractQueryFieldNodeRelatedOneToAny; + +/** + * Used to build a relational query for m2o and o2m relations. + * @example + * ``` + * const functionNode = { + * current: { + * fields: ['id'] + * }, + * external: { + * store: 'mongodb', + * collection: 'some-collection', + * } + * ``` + */ +export interface AbstractQueryFieldNodeRelatedJoinMany { + /** the field of the current collection which has the relational value to an external collection or item */ + current: { + fields: [string, ...string[]]; + }; + + /** the external collection or item which should be pulled/joined/merged into the current collection */ + external: { + store?: string; + collection: string; + fields: [string, ...string[]]; + }; +} + +export interface AbstractQueryFieldNodeRelatedJoinAny { + current: { + collectionField: string; + fields: [string, ...string[]]; + }; + + external: { + store?: string; + fields: [string, ...string[]]; + }; +} + +export interface AbstractQueryFieldNodeRelatedManyToOne extends AbstractQueryFieldNodeRelatedBase { + type: 'm2o'; + + join: AbstractQueryFieldNodeRelatedJoinMany; +} + +export interface AbstractQueryFieldNodeRelatedOneToMany extends AbstractQueryFieldNodeRelatedBase { + type: 'o2m'; + // maybe every here + join: AbstractQueryFieldNodeRelatedJoinMany; +} + +export interface AbstractQueryFieldNodeRelatedAnyToOne extends AbstractQueryFieldNodeRelatedBase { + type: 'a2o'; + + join: AbstractQueryFieldNodeRelatedJoinAny; +} + +export interface AbstractQueryFieldNodeRelatedOneToAny extends AbstractQueryFieldNodeRelatedBase { + type: 'o2a'; + + join: AbstractQueryFieldNodeRelatedJoinAny; +} + +/** + * Continue on it after relationships + * @deprecated Those information will probably go within the o2m relational node + **/ +export interface AbstractQueryQuantifierNode { + type: 'quantifier'; + operator: 'every' | 'some'; + + /** The o2m field that the every/some should be applied on */ + target: AbstractQueryFieldNodeRelatedOneToMany | AbstractQueryFieldNodeRelatedOneToAny; + + /** An alias to reference the o2m item */ + alias: string; + + /** the values for the the operation. */ + // childNode: AbstractQueryConditionNode | AbstractQueryNodeLogical | AbstractQueryNodeNegate; +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/field-condition.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/field-condition.ts new file mode 100644 index 0000000000..31d0899619 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/field-condition.ts @@ -0,0 +1,12 @@ +import type { AbstractQueryFieldNodePrimitive } from '../../../fields/primitive.js'; + +/** + * It's mainly used to compare two fields for relational queries. + * That's why only the qe comparator is valid. + */ +export interface ConditionFieldNode { + type: 'condition-field'; + target: AbstractQueryFieldNodePrimitive; + operation: 'eq'; + compareTo: AbstractQueryFieldNodePrimitive & { collection: string }; +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/geo-condition-bbox.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/geo-condition-bbox.ts new file mode 100644 index 0000000000..8f5f80c935 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/geo-condition-bbox.ts @@ -0,0 +1,13 @@ +import type { GeoJSONGeometryCollection, GeoJSONMultiPolygon, GeoJSONPolygon } from 'wellknown'; +import type { AbstractQueryFieldNodePrimitive } from '../../../fields/primitive.js'; + +/** + * Used to check if geo box objects intersect + */ +export interface ConditionGeoIntersectsBBoxNode { + type: 'condition-geo-intersects-bbox'; + target: AbstractQueryFieldNodePrimitive; + operation: 'intersects_bbox'; + compareTo: GeoJSONPolygon | GeoJSONMultiPolygon | GeoJSONGeometryCollection; + /** @TODO confirm if MultiPolygon works as expected across drivers */ +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/geo-condition.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/geo-condition.ts new file mode 100644 index 0000000000..e13d867bc2 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/geo-condition.ts @@ -0,0 +1,38 @@ +import type { AbstractQueryFieldNodePrimitive } from '../../../fields/primitive.js'; +import type { + GeoJSONGeometryCollection, + GeoJSONLineString, + GeoJSONMultiLineString, + GeoJSONMultiPoint, + GeoJSONPoint, +} from 'wellknown'; + +/** + * Checks if a non box geo object intersects with another. + * @example + * ``` + * { + * type: 'condition-geo', + * target: { + * type: 'primitive', + * field: 'attribute_xy' + * }, + * operation: 'intersects', + * compareTo: { + * "type": "Feature", + * "geometry": { + * "type": "Point", + * "coordinates": [125.6, 10.1] + * }, + * "properties": { + * "name": "Dinagat Islands" + * } + * } + * ``` + */ +export interface ConditionGeoIntersectsNode { + type: 'condition-geo-intersects'; + target: AbstractQueryFieldNodePrimitive /** the type of the field needs to be a 'geometry' object */; + operation: 'intersects'; + compareTo: GeoJSONPoint | GeoJSONMultiPoint | GeoJSONLineString | GeoJSONMultiLineString | GeoJSONGeometryCollection; +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/index.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/index.ts new file mode 100644 index 0000000000..b2dac3010c --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/index.ts @@ -0,0 +1,44 @@ +import type { ConditionGeoIntersectsNode } from './geo-condition.js'; +import type { ConditionGeoIntersectsBBoxNode } from './geo-condition-bbox.js'; +import type { ConditionFieldNode } from './field-condition.js'; +import type { ConditionNumberNode } from './number-condition.js'; +import type { ConditionSetNode } from './set-condition.js'; +import type { ConditionStringNode } from './string-condition.js'; +/** + * Used to specify a condition on a query. + * Note: No explicit support to check for 'empty' (it's just an empty string) and null. + * + * @example + * ``` + * { + * type: 'condition', + * condition: {...} + * }, + * ``` + */ +export interface AbstractQueryConditionNode { + type: 'condition'; + condition: ActualConditionNodes; +} + +/** + * Possible nodes which specify the condition. + * + * @todo The API should make sure, that the type of the targeting column has the correct type, + * so that f.e. a condition-string will only be applied to a column of type string. + */ +export type ActualConditionNodes = + | ConditionStringNode + | ConditionNumberNode + | ConditionGeoIntersectsNode + | ConditionGeoIntersectsBBoxNode + | ConditionSetNode + | ConditionFieldNode; + +// Those need to be exported to be used solely in the corresponding converter +export * from './field-condition.js'; +export * from './geo-condition-bbox.js'; +export * from './geo-condition.js'; +export * from './number-condition.js'; +export * from './string-condition.js'; +export * from './set-condition.js'; diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/number-condition.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/number-condition.ts new file mode 100644 index 0000000000..05be2e34fc --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/number-condition.ts @@ -0,0 +1,23 @@ +import type { AbstractQueryFieldNodeFn } from '../../../fields/function.js'; +import type { AbstractQueryFieldNodePrimitive } from '../../../fields/primitive.js'; + +/** + * Used to compare a number or date time field with a number value. + * @example + * ``` + * { + * type: 'condition-number', + * target: { + * type: 'primitive', + * field: 'attribute_xy' + * }, + * operation: 'lt', + * compareTo: 5 + * ``` + */ +export interface ConditionNumberNode { + type: 'condition-number'; + target: AbstractQueryFieldNodePrimitive | AbstractQueryFieldNodeFn; + operation: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'; + compareTo: number; +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/set-condition.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/set-condition.ts new file mode 100644 index 0000000000..a28561cb93 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/set-condition.ts @@ -0,0 +1,22 @@ +import type { AbstractQueryFieldNodePrimitive } from '../../../fields/primitive.js'; + +/** + * Used to compare a number field with a number value. + * @example + * ``` + * { + * type: 'condition-set', + * target: { + * type: 'primitive', + * field: 'attribute_xy' + * }, + * operation: 'in', + * compareTo: [1, 2, 3] + * ``` + */ +export interface ConditionSetNode { + type: 'condition-set'; + target: AbstractQueryFieldNodePrimitive; + operation: 'in'; + compareTo: (string | number)[]; // could also be an actual JS Set +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/conditions/string-condition.ts b/packages/data/src/types/abstract-query/modifiers/filters/conditions/string-condition.ts new file mode 100644 index 0000000000..675dfbd491 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/conditions/string-condition.ts @@ -0,0 +1,27 @@ +import type { AbstractQueryFieldNodePrimitive } from '../../../fields/primitive.js'; + +/** + * Used to compare a string field with a string value. + * + * @example + * ``` + * { + * type: 'condition-string', + * target: { + * type: 'primitive', + * field: 'attribute_xy' + * }, + * operation: 'contains', + * compareTo: 'someString' + * ``` + */ +export interface ConditionStringNode { + type: 'condition-string'; + + target: AbstractQueryFieldNodePrimitive; + + /** @TODO maybe also regex? */ + operation: 'contains' | 'starts_with' | 'ends_with' | 'eq'; + + compareTo: string; +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/index.ts b/packages/data/src/types/abstract-query/modifiers/filters/index.ts new file mode 100644 index 0000000000..7c04aad076 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/index.ts @@ -0,0 +1,9 @@ +import type { AbstractQueryConditionNode } from './conditions/index.js'; +import type { AbstractQueryNodeLogical } from './logical.js'; +import type { AbstractQueryNodeNegate } from './negate.js'; + +export * from './conditions/index.js'; +export * from './logical.js'; +export * from './negate.js'; + +export type AbstractQueryFilterNode = AbstractQueryConditionNode | AbstractQueryNodeLogical | AbstractQueryNodeNegate; diff --git a/packages/data/src/types/abstract-query/modifiers/filters/logical.ts b/packages/data/src/types/abstract-query/modifiers/filters/logical.ts new file mode 100644 index 0000000000..39800eed82 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/logical.ts @@ -0,0 +1,61 @@ +import type { AbstractQueryConditionNode } from './conditions/index.js'; +import type { AbstractQueryNodeNegate } from './negate.js'; + +/** + * Used to create logical operations. + * @example + * Let's say you want to only return rows where two conditions are true. + * First condition that some field value needs to be qual to a provided value and another condition that one field is less than another provided value. + * This would look like this: + * ``` + * { + * type: 'logical', + * operator: 'and', + * childNodes: [ + * { + * type: 'condition', + * condition: {...} + * }, + * { + * type: 'condition', + * condition: {...} + * } + * ] + * } + * ``` + * It is also possible to nest conditions with the logical operator. + * The following pseudo code mean: A AND (B AND C) + * ``` + * { + * type: 'logical', + * operator: 'and', + * childNodes: [ + * { + * type: 'condition', + * condition: {...} + * }, + * { + * type: 'logical', + * operator: 'and', + * childNodes: [ + * { + * type: 'condition', + * condition: {...} + * }, + * { + * type: 'condition', + * condition: {...} + * }, + * ], + * } + * ] + * } + * ``` + */ +export interface AbstractQueryNodeLogical { + type: 'logical'; + operator: 'and' | 'or'; + + /** the values for the operation. */ + childNodes: (AbstractQueryConditionNode | AbstractQueryNodeLogical | AbstractQueryNodeNegate)[]; +} diff --git a/packages/data/src/types/abstract-query/modifiers/filters/negate.ts b/packages/data/src/types/abstract-query/modifiers/filters/negate.ts new file mode 100644 index 0000000000..9f0b01fbe9 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/filters/negate.ts @@ -0,0 +1,11 @@ +import type { AbstractQueryFilterNode } from './index.js'; + +/** + * Specifies that the wrapper filter should be negated. + */ +export interface AbstractQueryNodeNegate { + type: 'negate'; + + /** the values for the operation. */ + childNode: AbstractQueryFilterNode; +} diff --git a/packages/data/src/types/abstract-query/modifiers/index.ts b/packages/data/src/types/abstract-query/modifiers/index.ts new file mode 100644 index 0000000000..beeb9a4934 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/index.ts @@ -0,0 +1,19 @@ +import type { AbstractQueryFilterNode } from './filters/index.js'; +import type { AbstractQueryNodeLimit } from './limit.js'; +import type { AbstractQueryNodeOffset } from './offset.js'; +import type { AbstractQueryNodeSort } from './sort.js'; + +/** + * Optional attributes to customize the query results + */ +export interface AbstractQueryModifiers { + limit?: AbstractQueryNodeLimit; + offset?: AbstractQueryNodeOffset; + sort?: AbstractQueryNodeSort[]; + filter?: AbstractQueryFilterNode; +} + +export * from './limit.js'; +export * from './offset.js'; +export * from './sort.js'; +export * from './filters/index.js'; diff --git a/packages/data/src/types/abstract-query/modifiers/limit.ts b/packages/data/src/types/abstract-query/modifiers/limit.ts new file mode 100644 index 0000000000..d2a29228e9 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/limit.ts @@ -0,0 +1,7 @@ +/** + * Specifies the maximum amount of returning results + */ +export interface AbstractQueryNodeLimit { + type: 'limit'; + value: number; +} diff --git a/packages/data/src/types/abstract-query/modifiers/offset.ts b/packages/data/src/types/abstract-query/modifiers/offset.ts new file mode 100644 index 0000000000..a801aad751 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/offset.ts @@ -0,0 +1,7 @@ +/** + * Specifies the number of items to skip before returning results + */ +export interface AbstractQueryNodeOffset { + type: 'offset'; + value: number; +} diff --git a/packages/data/src/types/abstract-query/modifiers/sort.ts b/packages/data/src/types/abstract-query/modifiers/sort.ts new file mode 100644 index 0000000000..d9a647c6f2 --- /dev/null +++ b/packages/data/src/types/abstract-query/modifiers/sort.ts @@ -0,0 +1,46 @@ +import type { AbstractQueryFieldNodeFn } from '../fields/function.js'; +import type { AbstractQueryFieldNodePrimitive } from '../fields/primitive.js'; + +/** + * Specifies the order of the result, f.e. for a primitive field. + * @example + * ```js + * const sortNode = { + * type: 'sort', + * direction: 'ascending', + * target: { + * type: 'primitive', + * field: 'attribute_xy' + * } + * } + * ``` + * Alternatively a function can be applied a the field. + * The result is then used for sorting. + * @example + * ```js + * const sortNode = { + * type: 'sort', + * direction: 'ascending', + * target: { + * type: 'fn', + * fn: 'year', + * targetNode: { + * type: 'primitive' + * field: 'date_created' + * } + * } + */ +export interface AbstractQueryNodeSort { + type: 'sort'; + + /** the desired order */ + direction: 'ascending' | 'descending'; + + /** the node on which the sorting should be applied */ + target: AbstractQueryFieldNodePrimitive | AbstractQueryFieldNodeFn; +} + +export type AbstractQueryNodeSortTargets = AbstractQueryFieldNodePrimitive | AbstractQueryFieldNodeFn; +/** @TODO support when working on relations */ +// | AbstractQueryFieldNodeRelatedManyToOne +// | AbstractQueryFieldNodeRelatedAnyToOne; diff --git a/packages/data/src/types/driver.ts b/packages/data/src/types/driver.ts index d758e75740..af1fd6d5fa 100644 --- a/packages/data/src/types/driver.ts +++ b/packages/data/src/types/driver.ts @@ -1,7 +1,8 @@ -import type { AbstractQuery } from './abstract-query.js'; +import type { AbstractQuery } from './abstract-query/abstract-query.js'; +import type { ReadableStream } from 'node:stream/web'; export abstract class DataDriver { - abstract query: (query: AbstractQuery) => Promise; + abstract query: (query: AbstractQuery) => Promise; /** * When the driver is first registered. Can be used to warm up caches, prepare connections to diff --git a/packages/data/src/types/index.ts b/packages/data/src/types/index.ts index f8c2301edd..fca2806e3c 100644 --- a/packages/data/src/types/index.ts +++ b/packages/data/src/types/index.ts @@ -1,2 +1,2 @@ -export * from './abstract-query.js'; +export * from './abstract-query/abstract-query.js'; export * from './driver.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d003d5bb5..06c86028aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1115,9 +1115,15 @@ importers: '@types/node': specifier: 18.16.12 version: 18.16.12 + '@types/wellknown': + specifier: 0.5.4 + version: 0.5.4 '@vitest/coverage-c8': specifier: 0.31.1 version: 0.31.1(vitest@0.31.1) + dependency-cruiser: + specifier: 13.1.4 + version: 13.1.4 tsup: specifier: 7.2.0 version: 7.2.0(typescript@5.2.2) @@ -1137,8 +1143,11 @@ importers: specifier: 8.10.0 version: 8.10.0 pg-query-stream: - specifier: ^4.5.0 - version: 4.5.3(pg@8.10.0) + specifier: 4.5.0 + version: 4.5.0(pg@8.10.0) + wellknown: + specifier: 0.5.0 + version: 0.5.0 devDependencies: '@directus/data': specifier: workspace:* @@ -1155,9 +1164,15 @@ importers: '@types/pg': specifier: 8.6.6 version: 8.6.6 + '@types/wellknown': + specifier: 0.5.4 + version: 0.5.4 '@vitest/coverage-c8': specifier: 0.31.1 version: 0.31.1(vitest@0.31.1) + dependency-cruiser: + specifier: 13.1.4 + version: 13.1.4 tsup: specifier: 7.2.0 version: 7.2.0(typescript@5.2.2) @@ -1169,6 +1184,10 @@ importers: version: 0.31.1(happy-dom@9.18.3)(sass@1.62.1) packages/data-sql: + dependencies: + lodash-es: + specifier: 4.17.21 + version: 4.17.21 devDependencies: '@directus/data': specifier: workspace:* @@ -1182,12 +1201,21 @@ importers: '@directus/types': specifier: workspace:* version: link:../types + '@types/lodash-es': + specifier: 4.17.7 + version: 4.17.7 '@types/node': specifier: 18.16.12 version: 18.16.12 + '@types/wellknown': + specifier: 0.5.4 + version: 0.5.4 '@vitest/coverage-c8': specifier: 0.31.1 version: 0.31.1(vitest@0.31.1) + dependency-cruiser: + specifier: 13.1.4 + version: 13.1.4 tsup: specifier: 7.2.0 version: 7.2.0(typescript@5.2.2) @@ -6008,6 +6036,18 @@ packages: resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} dev: false + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: true + /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -6642,6 +6682,13 @@ packages: - vue dev: true + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: true + optional: true + /@pkgr/utils@2.4.2: resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -9690,6 +9737,10 @@ packages: acorn: 8.10.0 dev: true + /acorn-jsx-walk@2.0.0: + resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} + dev: true + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -9698,6 +9749,13 @@ packages: acorn: 8.10.0 dev: true + /acorn-loose@8.3.0: + resolution: {integrity: sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==} + engines: {node: '>=0.4.0'} + dependencies: + acorn: 8.10.0 + dev: true + /acorn-walk@8.2.0: resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} engines: {node: '>=0.4.0'} @@ -10828,7 +10886,6 @@ packages: /chalk@5.3.0: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - dev: false /change-case@4.1.2: resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} @@ -11119,6 +11176,11 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + /commander@11.0.0: + resolution: {integrity: sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==} + engines: {node: '>=16'} + dev: true + /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -11968,6 +12030,39 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + /dependency-cruiser@13.1.4: + resolution: {integrity: sha512-cMrtPuPxp+uqQMt196IJ8lgKQglu+dbTsWgGHV+Y0AkWYJISjByluA1YcmPjglzXB9innj0s+oEcGE/N6Jm4aA==} + engines: {node: ^16.14||>=18} + hasBin: true + dependencies: + acorn: 8.10.0 + acorn-jsx: 5.3.2(acorn@8.10.0) + acorn-jsx-walk: 2.0.0 + acorn-loose: 8.3.0 + acorn-walk: 8.2.0 + ajv: 8.12.0 + chalk: 5.3.0 + commander: 11.0.0 + enhanced-resolve: 5.15.0 + figures: 5.0.0 + glob: 10.3.3 + ignore: 5.2.4 + indent-string: 5.0.0 + interpret: 3.1.1 + is-installed-globally: 0.4.0 + json5: 2.2.3 + lodash: 4.17.21 + prompts: 2.4.2 + rechoir: 0.8.0 + safe-regex: 2.1.1 + semver: 7.5.4 + semver-try-require: 6.2.3 + teamcity-service-messages: 0.1.14 + tsconfig-paths-webpack-plugin: 4.1.0 + watskeburt: 0.12.1 + wrap-ansi: 8.1.0 + dev: true + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -12987,7 +13082,6 @@ packages: dependencies: escape-string-regexp: 5.0.0 is-unicode-supported: 1.3.0 - dev: false /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} @@ -13215,6 +13309,14 @@ packages: signal-exit: 3.0.7 dev: true + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: true + /forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} requiresBuild: true @@ -13582,6 +13684,18 @@ packages: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: true + /glob@10.3.3: + resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.1 + minipass: 5.0.0 + path-scurry: 1.10.1 + dev: true + /glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} dependencies: @@ -13613,6 +13727,13 @@ packages: minimatch: 5.1.6 once: 1.4.0 + /global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + dependencies: + ini: 2.0.0 + dev: true + /globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -14229,6 +14350,11 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + /indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + dev: true + /individual@3.0.0: resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} dev: false @@ -14254,6 +14380,11 @@ packages: /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + /ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + dev: true + /ini@3.0.1: resolution: {integrity: sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -14297,6 +14428,11 @@ packages: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} + /interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + dev: true + /ioredis@5.3.2: resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} engines: {node: '>=12.22.0'} @@ -14496,6 +14632,14 @@ packages: is-docker: 3.0.0 dev: true + /is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + dev: true + /is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -14651,7 +14795,6 @@ packages: /is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} - dev: false /is-utf8@0.2.1: resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} @@ -14769,6 +14912,15 @@ packages: set-function-name: 2.0.1 dev: true + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + /jake@10.8.7: resolution: {integrity: sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==} engines: {node: '>=10'} @@ -15646,7 +15798,6 @@ packages: /lru-cache@9.1.2: resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==} engines: {node: 14 || >=16.14} - dev: false /lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -17639,6 +17790,14 @@ packages: /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + /path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 9.1.2 + minipass: 5.0.0 + dev: true + /path-temp@2.1.0: resolution: {integrity: sha512-cMMJTAZlion/RWRRC48UbrDymEIt+/YSD/l8NqjneyDw2rDOBQcP5yRkMB4CYGn47KMhZvbblBP7Z79OsMw72w==} engines: {node: '>=8.15'} @@ -17743,8 +17902,8 @@ packages: /pg-protocol@1.6.0: resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==} - /pg-query-stream@4.5.3(pg@8.10.0): - resolution: {integrity: sha512-ufa94r/lHJdjAm3+zPZEO0gXAmCb4tZPaOt7O76mjcxdL/HxwTuryy76km+u0odBBgtfdKFYq/9XGfiYeQF0yA==} + /pg-query-stream@4.5.0(pg@8.10.0): + resolution: {integrity: sha512-9slxIXMssuqKUVyCtuVU5/pr2+RLTKva5VE90PFzi6Mi8o3crbyZQvReoWJimgm9c1zY2+Jv3lvYYsqvaKmQ4g==} peerDependencies: pg: ^8 dependencies: @@ -19073,6 +19232,11 @@ packages: '@babel/runtime': 7.23.1 dev: true + /regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + dev: true + /regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} engines: {node: '>= 0.4'} @@ -19611,6 +19775,12 @@ packages: get-intrinsic: 1.2.1 is-regex: 1.1.4 + /safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + dependencies: + regexp-tree: 0.1.27 + dev: true + /safe-stable-stringify@2.4.3: resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} engines: {node: '>=10'} @@ -19709,6 +19879,13 @@ packages: resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} dev: true + /semver-try-require@6.2.3: + resolution: {integrity: sha512-6q1N/Vr/4/G0EcQ1k4svN5kwfh3MJs4Gfl+zBAVcKn+AeIjKLwTXQ143Y6YHu6xEeN5gSCbCD1/5+NwCipLY5A==} + engines: {node: ^14||^16||>=18} + dependencies: + semver: 7.5.4 + dev: true + /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -19899,7 +20076,6 @@ packages: /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - dev: false /simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -20851,6 +21027,10 @@ packages: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} engines: {node: '>=8.0.0'} + /teamcity-service-messages@0.1.14: + resolution: {integrity: sha512-29aQwaHqm8RMX74u2o/h1KbMLP89FjNiMxD9wbF2BbWOnbM+q+d1sCEC+MqCc4QW3NJykn77OMpTFw/xTHIc0w==} + dev: true + /tedious@16.1.0: resolution: {integrity: sha512-5W+shTkUoAyrB/Bbx89k6Q8Cb400OHzS6XDXQdsTp/obe1cFyOhNc1KI4FI6TOzklDGJWyLnEEfUSBVMpugnjA==} engines: {node: '>=16'} @@ -21217,6 +21397,24 @@ packages: typescript: 5.2.2 dev: true + /tsconfig-paths-webpack-plugin@4.1.0: + resolution: {integrity: sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==} + engines: {node: '>=10.13.0'} + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.15.0 + tsconfig-paths: 4.2.0 + dev: true + + /tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + dev: true + /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} dev: false @@ -22510,6 +22708,12 @@ packages: graceful-fs: 4.2.11 dev: true + /watskeburt@0.12.1: + resolution: {integrity: sha512-DHfW+OGKYFTei2jtylJg6ZLCO9P9BHMsf9v4Z4QBvocfdJ0gY+Re7K4rPmpkSpfNLdhzsvovPIQ4z836yViTNA==} + engines: {node: ^16.14||>=18} + hasBin: true + dev: true + /wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} dependencies: