mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
7
.changeset/light-worms-switch.md
Normal file
7
.changeset/light-worms-switch.md
Normal 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
|
||||
435
packages/data-driver-postgres/.dependency-cruiser.cjs
Normal file
435
packages/data-driver-postgres/.dependency-cruiser.cjs
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
217
packages/data-driver-postgres/src/index.test.ts
Normal file
217
packages/data-driver-postgres/src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"`);
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
124
packages/data-driver-postgres/src/query/join.test.ts
Normal file
124
packages/data-driver-postgres/src/query/join.test.ts
Normal 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}"`
|
||||
);
|
||||
});
|
||||
23
packages/data-driver-postgres/src/query/join.ts
Normal file
23
packages/data-driver-postgres/src/query/join.ts
Normal 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;
|
||||
};
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
47
packages/data-driver-postgres/src/query/parameters.test.ts
Normal file
47
packages/data-driver-postgres/src/query/parameters.test.ts
Normal 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))',
|
||||
]);
|
||||
});
|
||||
55
packages/data-driver-postgres/src/query/parameters.ts
Normal file
55
packages/data-driver-postgres/src/query/parameters.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(', ')}`;
|
||||
};
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"`);
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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}))`
|
||||
);
|
||||
});
|
||||
@@ -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})`;
|
||||
}
|
||||
};
|
||||
153
packages/data-driver-postgres/src/utils/conditions/index.test.ts
Normal file
153
packages/data-driver-postgres/src/utils/conditions/index.test.ts
Normal 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)`
|
||||
);
|
||||
});
|
||||
59
packages/data-driver-postgres/src/utils/conditions/index.ts
Normal file
59
packages/data-driver-postgres/src/utils/conditions/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
56
packages/data-driver-postgres/src/utils/functions.test.ts
Normal file
56
packages/data-driver-postgres/src/utils/functions.test.ts
Normal 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);
|
||||
});
|
||||
58
packages/data-driver-postgres/src/utils/functions.ts
Normal file
58
packages/data-driver-postgres/src/utils/functions.ts
Normal 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'" : ''})`;
|
||||
}
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { escapeIdentifier } from './escape-identifier.js';
|
||||
@@ -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
|
||||
|
||||
435
packages/data-sql/.dependency-cruiser.cjs
Normal file
435
packages/data-sql/.dependency-cruiser.cjs
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
21
packages/data-sql/readme.md
Normal file
21
packages/data-sql/readme.md
Normal 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.
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
17
packages/data-sql/src/orm/create-unique-alias.ts
Normal file
17
packages/data-sql/src/orm/create-unique-alias.ts
Normal 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}`;
|
||||
};
|
||||
106
packages/data-sql/src/orm/expand.test.ts
Normal file
106
packages/data-sql/src/orm/expand.test.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
44
packages/data-sql/src/orm/expand.ts
Normal file
44
packages/data-sql/src/orm/expand.ts
Normal 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;
|
||||
}
|
||||
43
packages/data-sql/src/orm/index.ts
Normal file
43
packages/data-sql/src/orm/index.ts
Normal 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';
|
||||
333
packages/data-sql/src/query-converter/converter.test.ts
Normal file
333
packages/data-sql/src/query-converter/converter.test.ts
Normal 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);
|
||||
});
|
||||
41
packages/data-sql/src/query-converter/converter.ts
Normal file
41
packages/data-sql/src/query-converter/converter.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
147
packages/data-sql/src/query-converter/fields/create-join.test.ts
Normal file
147
packages/data-sql/src/query-converter/fields/create-join.test.ts
Normal 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);
|
||||
});
|
||||
74
packages/data-sql/src/query-converter/fields/create-join.ts
Normal file
74
packages/data-sql/src/query-converter/fields/create-join.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
229
packages/data-sql/src/query-converter/fields/fields.test.ts
Normal file
229
packages/data-sql/src/query-converter/fields/fields.test.ts
Normal 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);
|
||||
});
|
||||
93
packages/data-sql/src/query-converter/fields/fields.ts
Normal file
93
packages/data-sql/src/query-converter/fields/fields.ts
Normal 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 };
|
||||
};
|
||||
1
packages/data-sql/src/query-converter/fields/index.ts
Normal file
1
packages/data-sql/src/query-converter/fields/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fields.js';
|
||||
116
packages/data-sql/src/query-converter/functions.test.ts
Normal file
116
packages/data-sql/src/query-converter/functions.test.ts
Normal 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],
|
||||
});
|
||||
});
|
||||
});
|
||||
42
packages/data-sql/src/query-converter/functions.ts
Normal file
42
packages/data-sql/src/query-converter/functions.ts
Normal 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 ?? [],
|
||||
};
|
||||
}
|
||||
1
packages/data-sql/src/query-converter/index.ts
Normal file
1
packages/data-sql/src/query-converter/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './converter.js';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './logical.js';
|
||||
export * from './filter.js';
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
2
packages/data-sql/src/query-converter/modifiers/index.ts
Normal file
2
packages/data-sql/src/query-converter/modifiers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './filter/index.js';
|
||||
export * from './sort.js';
|
||||
41
packages/data-sql/src/query-converter/modifiers/modifiers.ts
Normal file
41
packages/data-sql/src/query-converter/modifiers/modifiers.ts
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -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)[];
|
||||
}
|
||||
29
packages/data-sql/src/types/clauses/index.ts
Normal file
29
packages/data-sql/src/types/clauses/index.ts
Normal 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';
|
||||
25
packages/data-sql/src/types/clauses/joins/join.ts
Normal file
25
packages/data-sql/src/types/clauses/joins/join.ts
Normal 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;
|
||||
}
|
||||
7
packages/data-sql/src/types/clauses/order.ts
Normal file
7
packages/data-sql/src/types/clauses/order.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { AbstractQueryNodeSortTargets } from '@directus/data';
|
||||
|
||||
export interface AbstractSqlQueryOrderNode {
|
||||
type: 'order';
|
||||
orderBy: AbstractQueryNodeSortTargets;
|
||||
direction: 'ASC' | 'DESC';
|
||||
}
|
||||
27
packages/data-sql/src/types/clauses/selects/fn.ts
Normal file
27
packages/data-sql/src/types/clauses/selects/fn.ts
Normal 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;
|
||||
}
|
||||
8
packages/data-sql/src/types/clauses/selects/json.ts
Normal file
8
packages/data-sql/src/types/clauses/selects/json.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @TODO */
|
||||
// export interface SqlStatementSelectJson {
|
||||
// type: 'json';
|
||||
// table: string;
|
||||
// column: string;
|
||||
// as?: string;
|
||||
// path: string;
|
||||
// }
|
||||
22
packages/data-sql/src/types/clauses/selects/primitive.ts
Normal file
22
packages/data-sql/src/types/clauses/selects/primitive.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
Reference in New Issue
Block a user