Data abstraction: Field selection, modifiers and m2o for PostgreSQL driver (#19146)

* Naming typo

* First experiments

* Setup testing playground

* Fetch in client stream

* Add logging

* Remove unneeded stream to string util

* added logical operator to complex test and cleanup

* Improve typing

* started adding function support

* Support nested m2o in data-sql

* Implement join sql string creation

* It's alive!

* Remove unused aliases

* Add todo reminder

* fix build

* small improvements for fn conversion

* function conversion in pg driver

* more expressive typedocs

* toMatchObject in data sql to ignore alias and path

* added type to sort node

* moved and fixes tests for comparison

* moved condition tests

* separate file for functions

* test for function condition

* added proper args to function although not in use

* AT TIME ZONE 'UTC' when needed, proper arg value

* intersects support in pg driver

* convert geo value

* docs for intersects

* reworked column as function input

* support for functions in abstract select

* fixes tests

* count support in select

* refactoring: split up filter conversion

* starting every and some operators

Co-authored-by: Nicola Krumschmidt <nickrum@users.noreply.github.com>

* extracted variable in test

* in operator with sub query support

* split up conditions type into multiple types

* type for a single query parameter

* condition type

* intersects_bbox in pg driver

* finalized type declarations

* geo condition types

* pnpm lock update

* removed playground

* join

* fixed geo

* fixed types in test

* changeset

* made path prop required again

* geojson for intersects to the driver

* removed sub query from set-condition

* clean up form sub query removal

* added between support

* fixed geo condition test type

* formatting

* moved number operators to utils

* xy-condition to condition-xy

* remove between

* between clean up

* formatter

* refactoring: split up condition generation

* changeset

* split up type for geo condition

* refactoring: split up conditions converter

* fix formatter

* split up types

* split up abstract query into multiple files

* export fix

* split up condition builders in pg driver

* enabled all functions in select and nr condition

* adjusted join conversion and added test

* added test for node conversion

* moved index generator to converter dir

* docs

* format fix

* split up abstract query types further

* split up abstract sql types further

* split ups in converter and fine granular tests

* fix format

* proper index file in converter

* proper type for generator

* more docs on data-sql

* updated data readme

* readme tweaks

* removed circular dependency and added union type

* moved create-identifier function

* added index file in sql utils

* comment on converter usage

* removed circular deps in sql types

* last dependency fixes in data sql

* fixed tests

* cleanup up dependencies in overall abstract query

* removed base type to reduce noise

* reduce some index files in sql types declaration

* formatter

* more dependency tweaks

* formatter

* graphs for data and data-sql

* added dependency cruiser

* added lock file

* pnpm downgrade 8.6.0 to fixed lock file

* try fixing again with 8.6.2

* formatting

* formatting, now with correct formatter

* fixed dependencies

* formatter

* extracted variable for more readable code

Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>

* formatter

* fixed export of condition types for driver

* consistent file naming for conditions

* updated docs

* type fix

* removed todo

* another type fix

* split up expand func and added test

* todo test for driver class

* fixed unit test

* fixed typo

* removed todo, though about it, it's good as it is

* separated path map creation from query-converter

* renamed converter to query-converter

* user specific aliases for primitives

* fixed linter

* user specified alias for m2o

* documentation

* redesigned the function types and added alias support

* formatter

* unit test for pg driver index file

* moved call to source as class member

* moved alias map creation back to converter

* moved unique alias generation to ORM

* tsup update

* renamed nodes to fields

* redefined return type of query converter

* modifier conversion similar to fields conversion

* added response converter dir again

* aligned unit tests

* fixed circular dependency

* moved orm logic one level up again

* formatter

* removed SVGs

* Update pull_request_template.md

* Update pull_request_template.md

* Run formatter

* a bit renaming and restructuring

* formatter

---------

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
Co-authored-by: Nicola Krumschmidt <nickrum@users.noreply.github.com>
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
This commit is contained in:
Jan Arends
2023-10-06 15:32:04 +02:00
committed by GitHub
parent 8f1a7a83fd
commit d6e6208c26
136 changed files with 6439 additions and 1911 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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.

View File

@@ -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<string, any>[] = [];
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);
});
});

View File

@@ -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<Readable> {
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<ReadableStream> {
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);
}
}
}

View File

@@ -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}"`);
});

View File

@@ -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)}`;
}

View File

@@ -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;`
);
});

View File

@@ -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;
}

View File

@@ -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}"`
);
});

View File

@@ -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;
};

View File

@@ -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`);
});

View File

@@ -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;
}

View File

@@ -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`);
});

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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))',
]);
});

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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(', ')}`;
};

View File

@@ -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)`
);
});

View File

@@ -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}`);
}
}

View File

@@ -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}"`);
});

View File

@@ -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}`;
};

View File

@@ -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}))`
);
});

View File

@@ -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})`;
}
};

View File

@@ -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)`
);
});

View File

@@ -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;
}

View File

@@ -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}`
);
});

View File

@@ -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}`;
};

View File

@@ -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);
});

View File

@@ -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})`;
};

View File

@@ -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);
});

View File

@@ -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}`;
};

View File

@@ -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);
});

View File

@@ -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'" : ''})`;
}
};

View File

@@ -1 +0,0 @@
export { escapeIdentifier } from './escape-identifier.js';

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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.

View File

@@ -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],
});
});

View File

@@ -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<number, never, never>
): Required<Pick<AbstractSqlQuery, 'where' | 'parameters'>> => {
return convertFilterWithNegate(filter, collection, generator, false);
};
const convertFilterWithNegate = (
filter: AbstractQueryFilterNode,
collection: string,
generator: Generator<number, never, never>,
negate: boolean
): Required<Pick<AbstractSqlQuery, 'where' | 'parameters'>> => {
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),
};
}
};

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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;
};

View File

@@ -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';

View File

@@ -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}`;
};

View File

@@ -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',
},
},
});
});

View File

@@ -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<string, string[]>): 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<string, any>, paths: Map<string, string[]>): Record<string, any> {
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;
}

View File

@@ -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';

View File

@@ -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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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<Pick<AbstractSqlQuery, 'clauses' | 'parameters'>> = {
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);
});

View File

@@ -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,
};
};

View File

@@ -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);
});

View File

@@ -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,
},
},
};
}

View File

@@ -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,
});
});

View File

@@ -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;
};

View File

@@ -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);
});

View File

@@ -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<AbstractSqlClauses, 'select' | 'joins'>;
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<number, number, number>,
currentPath: string[] = []
): Result => {
const select: AbstractSqlClauses['select'] = [];
const joins: AbstractSqlClauses['joins'] = [];
const parameters: ParameterTypes[] = [];
const aliasRelationalMapping: Map<string, string[]> = 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 };
};

View File

@@ -0,0 +1 @@
export * from './fields.js';

View File

@@ -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<number, number, number>;
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],
});
});
});

View File

@@ -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 ?? [],
};
}

View File

@@ -0,0 +1 @@
export * from './converter.js';

View File

@@ -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<number, number, number>;
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();
});
});

View File

@@ -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<number, number, number>,
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);
}
}

View File

@@ -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: [],
});
});

View File

@@ -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: [],
};
}

View File

@@ -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],
});
});

View File

@@ -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<number, number, number>,
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],
};
}

View File

@@ -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<number, number, number>;
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],
});
});

View File

@@ -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<number, number, number>,
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],
};
}

View File

@@ -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,
});
});

View File

@@ -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<number, number, number>,
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],
};
}

View File

@@ -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],
});
});

View File

@@ -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<number, number, number>,
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],
};
}

View File

@@ -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;
}

View File

@@ -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<number, number, number>;
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],
});
});
});

View File

@@ -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<number, number, number>,
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`);
}
};

View File

@@ -0,0 +1,2 @@
export * from './logical.js';
export * from './filter.js';

View File

@@ -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],
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,2 @@
export * from './filter/index.js';
export * from './sort.js';

View File

@@ -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<AbstractSqlClauses, 'where' | 'limit' | 'offset' | 'order'>;
parameters: AbstractSqlQuery['parameters'];
};
export const convertModifiers = (
modifiers: AbstractQueryModifiers | undefined,
collection: string,
idxGenerator: Generator<number, number, number>
) => {
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;
};

View File

@@ -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',
},
]);

View File

@@ -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',
};

View File

@@ -1,7 +1,7 @@
/**
* Generator function to generate parameter indices.
*/
export function* parameterIndexGenerator(): Generator<number, never, never> {
export function* parameterIndexGenerator(): Generator<number, number, number> {
let index = 0;
while (true) {

View File

@@ -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)[];
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
import type { AbstractQueryNodeSortTargets } from '@directus/data';
export interface AbstractSqlQueryOrderNode {
type: 'order';
orderBy: AbstractQueryNodeSortTargets;
direction: 'ASC' | 'DESC';
}

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
/** @TODO */
// export interface SqlStatementSelectJson {
// type: 'json';
// table: string;
// column: string;
// as?: string;
// path: string;
// }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More