generated from avior/template-web-astro
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
POSTGRES_USERNAME=postgres
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DATABASE=
|
||||||
|
POSTGRES_HOST=
|
||||||
|
POSTGRES_PORT=5432
|
@ -68,5 +68,8 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
rewriting: true,
|
||||||
|
},
|
||||||
|
|
||||||
})
|
})
|
||||||
|
@ -14,7 +14,7 @@ import { objectLoop } from '@dzeio/object-util'
|
|||||||
*
|
*
|
||||||
* @returns the URL formatted with the params
|
* @returns the URL formatted with the params
|
||||||
*/
|
*/
|
||||||
export function formatRoute<T extends string>(url: T, params?: Record<string, string | number>): string {
|
export function formatRoute<T extends string>(url: T, params?: Record<string, string | number>, skipEncoding = false): string {
|
||||||
let result: string = url
|
let result: string = url
|
||||||
|
|
||||||
// early return if there are no params
|
// early return if there are no params
|
||||||
@ -28,9 +28,14 @@ export function formatRoute<T extends string>(url: T, params?: Record<string, st
|
|||||||
// loop through the parameters
|
// loop through the parameters
|
||||||
objectLoop(params, (value, key) => {
|
objectLoop(params, (value, key) => {
|
||||||
const search = \`[\${key}]\`
|
const search = \`[\${key}]\`
|
||||||
value = encodeURI(value.toString())
|
if (!skipEncoding) {
|
||||||
|
value = encodeURI(value.toString())
|
||||||
|
key = encodeURI(key)
|
||||||
|
} else {
|
||||||
|
value = value.toString()
|
||||||
|
}
|
||||||
if (!result.includes(search)) {
|
if (!result.includes(search)) {
|
||||||
externalQueries += \`\${encodeURI(key)}=\${value}&\`
|
externalQueries += \`\${key}=\${value}&\`
|
||||||
} else {
|
} else {
|
||||||
result = result.replace(search, value)
|
result = result.replace(search, value)
|
||||||
}
|
}
|
||||||
@ -53,8 +58,8 @@ async function updateRoutes(output: string, routes: Array<string>) {
|
|||||||
let file = baseFile
|
let file = baseFile
|
||||||
file += `\n\nexport type Routes = ${routes.map((it) => `'${it}'`).join(' | ')}`
|
file += `\n\nexport type Routes = ${routes.map((it) => `'${it}'`).join(' | ')}`
|
||||||
|
|
||||||
file += '\n\nexport default function route(route: Routes, query?: Record<string, string | number>) {'
|
file += '\n\nexport default function route(route: Routes, query?: Record<string, string | number>, skipEncoding = false) {'
|
||||||
file += '\n\treturn formatRoute(route, query)'
|
file += '\n\treturn formatRoute(route, query, skipEncoding)'
|
||||||
file += '\n}\n'
|
file += '\n}\n'
|
||||||
|
|
||||||
await fs.writeFile(output, file)
|
await fs.writeFile(output, file)
|
||||||
|
3643
package-lock.json
generated
3643
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,7 @@
|
|||||||
"@dzeio/url-manager": "^1",
|
"@dzeio/url-manager": "^1",
|
||||||
"astro": "^4",
|
"astro": "^4",
|
||||||
"lucide-astro": "^0",
|
"lucide-astro": "^0",
|
||||||
|
"pg": "^8.11.5",
|
||||||
"sharp": "^0",
|
"sharp": "^0",
|
||||||
"simple-icons-astro": "^10",
|
"simple-icons-astro": "^10",
|
||||||
"tailwindcss": "^3"
|
"tailwindcss": "^3"
|
||||||
@ -30,6 +31,7 @@
|
|||||||
"@astrojs/check": "^0",
|
"@astrojs/check": "^0",
|
||||||
"@playwright/test": "^1",
|
"@playwright/test": "^1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
"@vitest/coverage-v8": "^1",
|
"@vitest/coverage-v8": "^1",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^1"
|
"vitest": "^1"
|
||||||
|
5
src/env.d.ts
vendored
5
src/env.d.ts
vendored
@ -5,6 +5,11 @@
|
|||||||
* Environment variables declaration
|
* Environment variables declaration
|
||||||
*/
|
*/
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
POSTGRES_USER: string
|
||||||
|
POSTGRES_PASSWORD: string
|
||||||
|
POSTGRES_DATABASE: string
|
||||||
|
POSTGRES_HOST: string
|
||||||
|
POSTGRES_PORT: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
@ -16,3 +16,9 @@ export interface Props extends HeadProps {
|
|||||||
<slot />
|
<slot />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Hyperion from 'libs/Hyperion'
|
||||||
|
|
||||||
|
Hyperion.setup()
|
||||||
|
</script>
|
||||||
|
3
src/libs/AsyncUtils.ts
Normal file
3
src/libs/AsyncUtils.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function wait(timeMs: number) {
|
||||||
|
return new Promise((res) => setTimeout(res, timeMs))
|
||||||
|
}
|
49
src/libs/Env.ts
Normal file
49
src/libs/Env.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { ImportMetaEnv } from 'env'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const defaults: Array<keyof ImportMetaEnv> = [
|
||||||
|
'POSTGRES_USERNAME',
|
||||||
|
'POSTGRES_PASSWORD',
|
||||||
|
'POSTGRES_DATABASE',
|
||||||
|
'POSTGRES_HOST',
|
||||||
|
'POSTGRES_PORT'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the environment variable
|
||||||
|
*
|
||||||
|
* @param key the env variable key
|
||||||
|
* @param defaultValue a default value if applicable
|
||||||
|
* @returns the environment value or undefined if not found
|
||||||
|
*/
|
||||||
|
export function getEnv(key: keyof ImportMetaEnv, defaultValue: string): string
|
||||||
|
export function getEnv(key: keyof ImportMetaEnv, defaultValue?: string | undefined): string | undefined
|
||||||
|
export function getEnv(key: keyof ImportMetaEnv, defaultValue?: string | undefined): string | undefined {
|
||||||
|
// get the env variable through Astro > NodeJS > input
|
||||||
|
const res = import.meta.env[key] ?? process.env[key] ?? defaultValue
|
||||||
|
|
||||||
|
return res ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the environment variable and throws if not found
|
||||||
|
*
|
||||||
|
* @throws {Error} if the env variable is not found
|
||||||
|
* @param key the env variable key
|
||||||
|
* @param defaultValue a default value if applicable
|
||||||
|
* @returns the environment value
|
||||||
|
*/
|
||||||
|
export function requireEnv(key: keyof ImportMetaEnv, defaultValue?: string): string {
|
||||||
|
// get the env variable through Astro > NodeJS > input
|
||||||
|
const res = getEnv(key, defaultValue)
|
||||||
|
|
||||||
|
// throw if env variable is not set
|
||||||
|
if (!res) {
|
||||||
|
throw new Error(`MissingEnvError: the env ${key} is not set!`)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export function envExists(key: keyof ImportMetaEnv): boolean {
|
||||||
|
return !!(import.meta.env[key] ?? process.env[key])
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { objectLoop, objectSize } from '@dzeio/object-util'
|
import { mustBeObject, objectLoop, objectSize } from '@dzeio/object-util'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hyperion is a library that allow you to make changes to the HTML content by using a mix of `<template />` and `JSON` Reponse from API endpoints
|
* Hyperion is a library that allow you to make changes to the HTML content by using a mix of `<template />` and `JSON` Reponse from API endpoints
|
||||||
@ -63,7 +63,7 @@ export default class Hyperion {
|
|||||||
*/
|
*/
|
||||||
private initItem(it: HTMLElement) {
|
private initItem(it: HTMLElement) {
|
||||||
// get the trigger action
|
// get the trigger action
|
||||||
let trigger = it.dataset.trigger ?? 'click'
|
let trigger = it.dataset.trigger ?? (it.tagName === 'FORM' ? 'submit' : 'click')
|
||||||
|
|
||||||
// the triggering options
|
// the triggering options
|
||||||
const options: {
|
const options: {
|
||||||
@ -73,33 +73,38 @@ export default class Hyperion {
|
|||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
// handle options
|
// handle options
|
||||||
if (trigger.includes(' ')) {
|
const splitted = trigger.split(' ')
|
||||||
const splitted = trigger.split(' ')
|
for (const item of splitted) {
|
||||||
trigger = splitted.shift()!
|
console.log('ah', splitted, item)
|
||||||
for (const item of splitted) {
|
// item runs only once
|
||||||
// item runs only once
|
if (item === 'once') {
|
||||||
if (item === 'once') {
|
options.once = true
|
||||||
options.once = true
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (item === 'load') {
|
|
||||||
options.load = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (item.includes(':')) {
|
|
||||||
const [key, value] = item.split(':', 2)
|
|
||||||
// trigger is done after {x}ms (if another trigger is not done)
|
|
||||||
if (key === 'after') {
|
|
||||||
options.after = parseInt(value!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (item === 'load') {
|
||||||
|
options.load = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.includes(':')) {
|
||||||
|
const [key, value] = item.split(':', 2)
|
||||||
|
// trigger is done after {x}ms (if another trigger is not done)
|
||||||
|
if (key === 'after') {
|
||||||
|
options.after = parseInt(value!)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger is not special
|
||||||
|
trigger = item
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeout: NodeJS.Timeout | undefined
|
let timeout: NodeJS.Timeout | undefined
|
||||||
|
|
||||||
// the triggering function
|
// the triggering function
|
||||||
let fn = () => {
|
let fn = (ev?: Event) => {
|
||||||
|
if (ev) {
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
if (options.after) { // handle options:after
|
if (options.after) { // handle options:after
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@ -136,11 +141,13 @@ export default class Hyperion {
|
|||||||
* @param it the element to process
|
* @param it the element to process
|
||||||
*/
|
*/
|
||||||
private async processElement(it: HTMLElement) {
|
private async processElement(it: HTMLElement) {
|
||||||
|
console.log(it)
|
||||||
const action = it.dataset.action // get the URL to try
|
const action = it.dataset.action // get the URL to try
|
||||||
const method = it.dataset.method ?? 'GET' // get the method to use
|
const method = it.dataset.method ?? 'GET' // get the method to use
|
||||||
const subPath = it.dataset.path // get a subPath if desired (dot-separated values)
|
const subPath = it.dataset.path // get a subPath if desired (dot-separated values)
|
||||||
let target = it.dataset.target ?? 'innerHTML' // indicate how the data is set
|
let target = it.dataset.target ?? 'innerHTML' // indicate how the data is set
|
||||||
const templateQuery = it.dataset.template // get the template query
|
const templateQuery = it.dataset.template // get the template query
|
||||||
|
const runQuery = it.dataset.run // get the template query
|
||||||
const multiple = it.hasAttribute('data-multiple') // get if the result should be an array
|
const multiple = it.hasAttribute('data-multiple') // get if the result should be an array
|
||||||
const params: Record<string, any> = {} // the request parameters
|
const params: Record<string, any> = {} // the request parameters
|
||||||
|
|
||||||
@ -149,28 +156,17 @@ export default class Hyperion {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// handle mendatory elements that are not necessary set
|
// handle mendatory elements that are not necessary set
|
||||||
if (!templateQuery || !action) {
|
if ((!templateQuery && !runQuery) || !action) {
|
||||||
it.dispatchEvent(new CustomEvent('hyperion:attributeError', {
|
it.dispatchEvent(new CustomEvent('hyperion:attributeError', {
|
||||||
detail: {
|
detail: {
|
||||||
reason: 'data-template or data-action not found'
|
reason: 'data-template/data-run or data-action not found'
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
throw new Error(`the data-template (${templateQuery}) or data-action (${action}) attribute is not set :(`)
|
throw new Error(`the data-template (${templateQuery}) and data-run (${runQuery}) or data-action (${action}) attribute is not set :(`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(action, window.location.href)
|
const url = new URL(action, window.location.href)
|
||||||
|
|
||||||
// query the remote template
|
|
||||||
const template = document.querySelector<HTMLTemplateElement>(templateQuery)
|
|
||||||
if (!template) {
|
|
||||||
it.dispatchEvent(new CustomEvent('hyperion:templateError', {
|
|
||||||
detail: {
|
|
||||||
reason: 'template not found'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
throw new Error(`the Template for the query ${templateQuery} was not found ! :(`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pre-process the target attribute
|
// pre-process the target attribute
|
||||||
let targetEl: HTMLElement | null = it
|
let targetEl: HTMLElement | null = it
|
||||||
|
|
||||||
@ -195,7 +191,7 @@ export default class Hyperion {
|
|||||||
|
|
||||||
// handle if the target was not found
|
// handle if the target was not found
|
||||||
if (!targetEl) {
|
if (!targetEl) {
|
||||||
template.dispatchEvent(new CustomEvent('hyperion:targetError', {
|
it.dispatchEvent(new CustomEvent('hyperion:targetError', {
|
||||||
detail: {
|
detail: {
|
||||||
type: 'target not found',
|
type: 'target not found',
|
||||||
data: target
|
data: target
|
||||||
@ -253,6 +249,32 @@ export default class Hyperion {
|
|||||||
// transform the response into JSON
|
// transform the response into JSON
|
||||||
let json = await res.json()
|
let json = await res.json()
|
||||||
|
|
||||||
|
if (runQuery) {
|
||||||
|
const runEl = document.querySelector<HTMLElement>(runQuery)
|
||||||
|
if (!runEl) {
|
||||||
|
it.dispatchEvent(new CustomEvent('hyperion:runError', {
|
||||||
|
detail: {
|
||||||
|
reason: 'run element not found'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
throw new Error(`the run Element for the query ${runQuery} was not found ! :(`)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hyperion.trigger(runEl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// query the remote template
|
||||||
|
const template = document.querySelector<HTMLTemplateElement>(templateQuery!)
|
||||||
|
if (!template) {
|
||||||
|
it.dispatchEvent(new CustomEvent('hyperion:templateError', {
|
||||||
|
detail: {
|
||||||
|
reason: 'template not found'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
throw new Error(`the Template for the query ${templateQuery} was not found ! :(`)
|
||||||
|
}
|
||||||
|
|
||||||
// if subPath was set get to the path described
|
// if subPath was set get to the path described
|
||||||
if (subPath) {
|
if (subPath) {
|
||||||
for (const child of subPath.split('.')) {
|
for (const child of subPath.split('.')) {
|
||||||
@ -315,18 +337,55 @@ export default class Hyperion {
|
|||||||
// clone the template
|
// clone the template
|
||||||
const clone = template.content.cloneNode(true) as HTMLElement
|
const clone = template.content.cloneNode(true) as HTMLElement
|
||||||
|
|
||||||
|
clone.querySelectorAll<HTMLElement>('[data-loop]').forEach((it) => {
|
||||||
|
const attr = it.dataset.loop!
|
||||||
|
const tpl = it.children.item(0)!
|
||||||
|
const childContent = attr === 'this' ? data : data[attr]
|
||||||
|
console.log(it, childContent)
|
||||||
|
if (!Array.isArray(childContent)) {
|
||||||
|
throw new Error('child MUST be an array')
|
||||||
|
}
|
||||||
|
for (let idx = 0; idx < childContent.length; idx++) {
|
||||||
|
const child = tpl!.cloneNode(true) as HTMLElement
|
||||||
|
console.log(child)
|
||||||
|
let childAttr = child.dataset.attribute
|
||||||
|
if (childAttr === 'this') childAttr = idx.toString()
|
||||||
|
child.dataset.attribute = attr + '.' + childAttr
|
||||||
|
it.appendChild(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
it.removeChild(tpl)
|
||||||
|
})
|
||||||
|
|
||||||
// go through every elements that has a attribute to fill
|
// go through every elements that has a attribute to fill
|
||||||
clone.querySelectorAll<HTMLElement>('[data-attribute]').forEach((it) => {
|
clone.querySelectorAll<HTMLElement>('[data-attribute]').forEach((it) => {
|
||||||
// get the raw attribute
|
// get the raw attribute
|
||||||
const attrRaw = it.dataset.attribute!
|
const attrRaw = it.dataset.attribute!
|
||||||
|
|
||||||
// parse into an array
|
// parse into an array
|
||||||
let attrs: Array<string>
|
let attrs: Array<string> = []
|
||||||
if (attrRaw.includes(' ')) {
|
let quoteCount = 0
|
||||||
attrs = attrRaw.split(' ')
|
let current = ''
|
||||||
} else {
|
const splitted = attrRaw.split('')
|
||||||
attrs = [attrRaw]
|
for (let idx = 0; idx < splitted.length; idx++) {
|
||||||
|
const char = splitted[idx];
|
||||||
|
if (char === '\'' && splitted[idx - 1] != '\\') {
|
||||||
|
quoteCount += 1
|
||||||
|
continue
|
||||||
|
} else if (char === ' ' && quoteCount % 2 === 0) {
|
||||||
|
attrs.push(current)
|
||||||
|
current = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === '\'' && splitted[idx - 1] === '\\') {
|
||||||
|
current = current.slice(0, current.length - 1)
|
||||||
|
}
|
||||||
|
current += char
|
||||||
}
|
}
|
||||||
|
if (current) {
|
||||||
|
attrs.push(current)
|
||||||
|
}
|
||||||
|
console.log(attrs)
|
||||||
|
|
||||||
// loop through each attributes
|
// loop through each attributes
|
||||||
for (let attr of attrs) {
|
for (let attr of attrs) {
|
||||||
@ -352,7 +411,7 @@ export default class Hyperion {
|
|||||||
}
|
}
|
||||||
content = attr
|
content = attr
|
||||||
} else { // handle basic string
|
} else { // handle basic string
|
||||||
content = attr === 'this' ? data : data[attr]
|
content = attr === 'this' ? data : objectGet(data, attr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setToAttribute) { // set it as attribute
|
if (setToAttribute) { // set it as attribute
|
||||||
@ -380,8 +439,8 @@ export default class Hyperion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// idk if necessary but remove the attributes from the final HTML
|
// idk if necessary but remove the attributes from the final HTML
|
||||||
it.removeAttribute('data-attribute')
|
// it.removeAttribute('data-attribute')
|
||||||
it.removeAttribute('data-type')
|
// it.removeAttribute('data-type')
|
||||||
})
|
})
|
||||||
|
|
||||||
// setup the clone to work if it contains Hyperion markup
|
// setup the clone to work if it contains Hyperion markup
|
||||||
@ -390,3 +449,36 @@ export default class Hyperion {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* go through an object to get a specific value
|
||||||
|
*
|
||||||
|
* note: it will be slower than getting it directly but it allows some dynamism and other libs to query things
|
||||||
|
*
|
||||||
|
* @param obj the object to go through
|
||||||
|
* @param path the path to follow (if path is a string it will be splitted with `.` and ints will be parsed)
|
||||||
|
*
|
||||||
|
* @returns the value or undefined
|
||||||
|
*/
|
||||||
|
function objectGet(obj: object, path: Array<string | number | symbol> | string): any | undefined {
|
||||||
|
mustBeObject(obj)
|
||||||
|
|
||||||
|
// transform path into an Array
|
||||||
|
if (typeof path === 'string') {
|
||||||
|
path = path.split('.').map((it) => /^\d+$/g.test(it) ? parseInt(it) : it)
|
||||||
|
}
|
||||||
|
let pointer: object = obj
|
||||||
|
for (let index = 0; index < path.length; index++) {
|
||||||
|
const key = path[index]
|
||||||
|
const nextIndex = index + 1;
|
||||||
|
if (typeof key === 'undefined' || !Object.prototype.hasOwnProperty.call(pointer, key) && nextIndex < path.length) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// if last index
|
||||||
|
if (nextIndex === path.length) {
|
||||||
|
return (pointer as any)[key]
|
||||||
|
}
|
||||||
|
// move pointer to new key
|
||||||
|
pointer = (pointer as any)[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -13,7 +13,7 @@ export default defineMiddleware(async ({ request, url }, next) => {
|
|||||||
const path = fullURL.slice(fullURL.indexOf(url.pathname, fullURL.indexOf(url.host)))
|
const path = fullURL.slice(fullURL.indexOf(url.pathname, fullURL.indexOf(url.host)))
|
||||||
|
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
// time of request
|
// time of request
|
||||||
prefix = `\x1b[2m${new Date().toLocaleTimeString('fr')}\x1b[22m`
|
prefix = `\x1b[2m${new Date().toLocaleTimeString('fr')}\x1b[22m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
203
src/models/Adapters/AdapterUtils.ts
Normal file
203
src/models/Adapters/AdapterUtils.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { objectFind, objectLoop } from '@dzeio/object-util'
|
||||||
|
import { Sort, type Query, type QueryList, type QueryValues } from 'models/Query'
|
||||||
|
|
||||||
|
export declare type AllowedValues = string | number | bigint | boolean | null | undefined
|
||||||
|
|
||||||
|
export function filter<T extends object>(query: Query<T>, results: Array<T>, options?: { debug?: boolean }): Array<T> {
|
||||||
|
if (options?.debug) {
|
||||||
|
console.log('Query', query)
|
||||||
|
}
|
||||||
|
// filter
|
||||||
|
let filtered = results.filter((it) => {
|
||||||
|
const res = objectLoop(query, (value, key) => {
|
||||||
|
if (key === '$or') {
|
||||||
|
for (const sub of value as any) {
|
||||||
|
const final = filterEntry(sub, it)
|
||||||
|
// eslint-disable-next-line max-depth
|
||||||
|
if (final) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ((key as string).startsWith('$')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return filterEntry(query, it)
|
||||||
|
})
|
||||||
|
// console.log(it, res)
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
if (options?.debug) {
|
||||||
|
console.log('postFilters', filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort
|
||||||
|
if (query.$sort) {
|
||||||
|
// temp until better solution is found
|
||||||
|
const first = objectFind(query.$sort, () => true)
|
||||||
|
filtered = filtered.sort((a, b) => {
|
||||||
|
if (first?.value === Sort.ASC) {
|
||||||
|
return (a[first!.key] ?? 0) > (b[first!.key] ?? 0) ? 1 : -1
|
||||||
|
}
|
||||||
|
return (a[first!.key] ?? 0) > (b[first!.key] ?? 0) ? -1 : 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (options?.debug) {
|
||||||
|
console.log('postSort', filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit
|
||||||
|
if (query.$offset || query.$limit) {
|
||||||
|
const offset = query.$offset ?? 0
|
||||||
|
filtered = filtered.slice(offset, offset + (query.$limit ?? 0))
|
||||||
|
}
|
||||||
|
if (options?.debug) {
|
||||||
|
console.log('postLimit', filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param query the query of the entry
|
||||||
|
* @param item the implementation of the item
|
||||||
|
* @returns if it should be kept or not
|
||||||
|
*/
|
||||||
|
export function filterEntry<T extends object>(query: QueryList<T>, item: T): boolean {
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
const res = objectLoop(query as any, (queryValue, key: keyof typeof query) => {
|
||||||
|
/**
|
||||||
|
* TODO: handle $keys
|
||||||
|
*/
|
||||||
|
if ((key as string).startsWith('$')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterValue(item[key], queryValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* indicate if a value should be kept by an ENTIRE query
|
||||||
|
*
|
||||||
|
* @param value the value to filter
|
||||||
|
* @param query the full query
|
||||||
|
* @returns if the query should keep the value or not
|
||||||
|
*/
|
||||||
|
function filterValue<T extends AllowedValues>(value: any, query: QueryValues<T>) {
|
||||||
|
if (typeof query !== 'object' || query === null || query instanceof RegExp || Array.isArray(query)) {
|
||||||
|
return filterItem(value, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop through each keys of the query
|
||||||
|
// eslint-disable-next-line arrow-body-style
|
||||||
|
return objectLoop(query, (querySubValue: any, queryKey: any) => {
|
||||||
|
return filterItem(value, {[queryKey]: querySubValue } as QueryValues<T>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value the value to check
|
||||||
|
* @param query a SINGLE query to check against
|
||||||
|
* @returns if the value should be kept or not
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
function filterItem(value: any, query: QueryValues<AllowedValues>): boolean {
|
||||||
|
/**
|
||||||
|
* check if the value is null
|
||||||
|
*/
|
||||||
|
if (query === null) {
|
||||||
|
return typeof value === 'undefined' || value === null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query instanceof RegExp) {
|
||||||
|
return query.test(typeof value === 'string' ? value : value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ?!?
|
||||||
|
*/
|
||||||
|
if (value === null || typeof value === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* strict value check by default
|
||||||
|
*/
|
||||||
|
if (!(typeof query === 'object')) {
|
||||||
|
return query === value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array checking and $in
|
||||||
|
*/
|
||||||
|
if (Array.isArray(query) || '$in' in query) {
|
||||||
|
const arr = Array.isArray(query) ? query : query.$in as Array<AllowedValues>
|
||||||
|
return arr.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$inc' in query) {
|
||||||
|
return value.toString().includes(query.$inc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$eq' in query) {
|
||||||
|
return query.$eq === value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* numbers specific cases for numbers
|
||||||
|
*/
|
||||||
|
if ('$gt' in query) {
|
||||||
|
value = value instanceof Date ? value.getTime() : value
|
||||||
|
const comparedValue = query.$gt instanceof Date ? query.$gt.getTime() : query.$gt
|
||||||
|
return typeof value === 'number' && typeof comparedValue === 'number' && value > comparedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$lt' in query) {
|
||||||
|
value = value instanceof Date ? value.getTime() : value
|
||||||
|
const comparedValue = query.$lt instanceof Date ? query.$lt.getTime() : query.$lt
|
||||||
|
return typeof value === 'number' && typeof comparedValue === 'number' && value < comparedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$gte' in query) {
|
||||||
|
value = value instanceof Date ? value.getTime() : value
|
||||||
|
const comparedValue = query.$gte instanceof Date ? query.$gte.getTime() : query.$gte
|
||||||
|
return typeof value === 'number' && typeof comparedValue === 'number' && value >= comparedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$lte' in query) {
|
||||||
|
value = value instanceof Date ? value.getTime() : value
|
||||||
|
const comparedValue = query.$lte instanceof Date ? query.$lte.getTime() : query.$lte
|
||||||
|
return typeof value === 'number' && typeof comparedValue === 'number' && value <= comparedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logical Operators
|
||||||
|
*/
|
||||||
|
if ('$or' in query && Array.isArray(query.$or)) {
|
||||||
|
return !!query.$or.find((it) => filterValue(value, it as QueryValues<any>))
|
||||||
|
}
|
||||||
|
if ('$and' in query && Array.isArray(query.$and)) {
|
||||||
|
return !query.$and.find((it) => !filterValue(value, it as QueryValues<any>))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$not' in query) {
|
||||||
|
return !filterValue(value, query.$not as QueryValues<any>)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$nor' in query && Array.isArray(query.$nor)) {
|
||||||
|
return !query.$nor.find((it) => filterValue(value, it as QueryValues<any>))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('$nand' in query && Array.isArray(query.$nand)) {
|
||||||
|
return !!query.$nand.find((it) => !filterValue(value, it as QueryValues<any>))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
419
src/models/Adapters/CassandraAdapter.ts
Normal file
419
src/models/Adapters/CassandraAdapter.ts
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
|
||||||
|
import { types, type ArrayOrObject } from 'cassandra-driver'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import Client from '../Client'
|
||||||
|
import type DaoAdapter from '../DaoAdapter'
|
||||||
|
import type { DBPull } from '../DaoAdapter'
|
||||||
|
import { Sort, type Query } from '../Query'
|
||||||
|
import type Schema from '../Schema'
|
||||||
|
import { isSchemaItem, type Implementation, type Item, type Model } from '../Schema'
|
||||||
|
import { filter } from './AdapterUtils'
|
||||||
|
|
||||||
|
export default class CassandraAdapter<T extends Model> implements DaoAdapter<T> {
|
||||||
|
|
||||||
|
private id!: Array<string>
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
/**
|
||||||
|
* the schema used by Cassandra
|
||||||
|
*/
|
||||||
|
public readonly schema: Schema<T>,
|
||||||
|
/**
|
||||||
|
* the table name
|
||||||
|
*/
|
||||||
|
public readonly table: string,
|
||||||
|
/**
|
||||||
|
* the id(s)
|
||||||
|
*/
|
||||||
|
id?: keyof T | Array<keyof T>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* other secondary keys necessary to update data
|
||||||
|
*/
|
||||||
|
private readonly partitionKeys?: Array<keyof T>,
|
||||||
|
/**
|
||||||
|
* additionnal options to make the adapter work
|
||||||
|
*/
|
||||||
|
private readonly options?: {
|
||||||
|
/**
|
||||||
|
* log the requests made to cassandra
|
||||||
|
*/
|
||||||
|
debug?: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!id) {
|
||||||
|
objectLoop(schema.model, (value, key) => {
|
||||||
|
if (!isSchemaItem(value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!value.database?.unique) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
id = key
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.id = typeof id === 'string' ? [id] : id as Array<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make it clearer what it does
|
||||||
|
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (isSchemaItem(item) && (item.database?.created || item.database?.updated)) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = new Date()
|
||||||
|
} else if (isSchemaItem(item) && item.database?.auto && !obj[key]) {
|
||||||
|
if (item.type === String) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = crypto.randomBytes(16).toString('hex')
|
||||||
|
} else {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = crypto.randomBytes(16).readUint32BE()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const clone = this.schema.parse(obj)
|
||||||
|
if (!clone) {
|
||||||
|
throw new Error('Invalid data given to create the final object')
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = objectKeys(clone)
|
||||||
|
const keysStr = keys.join(', ')
|
||||||
|
const values = keys.fill('?').join(', ')
|
||||||
|
const req = `INSERT INTO ${this.table} (${keysStr}) VALUES (${values});`
|
||||||
|
const client = (await Client.get())!
|
||||||
|
|
||||||
|
const params = objectMap(clone as any, (value, key) => this.valueToDB(key as any, value))
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(req, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.execute(req, params, { prepare: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e, req, params)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.schema.parse(clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||||
|
let req: Array<string> = ['SELECT', '*', 'FROM', this.table]
|
||||||
|
const params: ArrayOrObject = []
|
||||||
|
|
||||||
|
// list of the differents items in the WHERE statement
|
||||||
|
const whereItems: Array<string> = []
|
||||||
|
// if ((query?.where?.length ?? 0) > 0 && (query?.where?.length !== 1 || query?.where?.[0]?.[1] !== 'includes')) {
|
||||||
|
// for (const it of query?.where ?? []) {
|
||||||
|
// // eslint-disable-next-line max-depth
|
||||||
|
// switch (it[1]) {
|
||||||
|
// case 'in':
|
||||||
|
// // eslint-disable-next-line no-case-declarations
|
||||||
|
// const arr = it[2] as Array<any>
|
||||||
|
|
||||||
|
// whereItems.push(`${String(it[0])} IN (${arr.map(() => '?').join(',')})`)
|
||||||
|
// params.push(...arr)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case 'equal':
|
||||||
|
// whereItems.push(`${String(it[0])} = ?`)
|
||||||
|
// params.push(it[2])
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case 'after':
|
||||||
|
// whereItems.push(`${String(it[0])} >= ?`)
|
||||||
|
// params.push(it[2])
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case 'before':
|
||||||
|
// whereItems.push(`${String(it[0])} <= ?`)
|
||||||
|
// params.push(it[2])
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (whereItems.length > 0) {
|
||||||
|
req.push('WHERE')
|
||||||
|
for (let idx = 0; idx < whereItems.length; idx++) {
|
||||||
|
const item = whereItems[idx] as string
|
||||||
|
if (idx > 0) {
|
||||||
|
req.push('AND')
|
||||||
|
}
|
||||||
|
req.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER BY (not working as we want :()
|
||||||
|
// const sort = query?.$sort
|
||||||
|
// if (sort && sort.length >= 1) {
|
||||||
|
// const suffix = sort[0]?.[1] === 'asc' ? 'ASC' : 'DESC'
|
||||||
|
// req = req.concat(['ORDER', 'BY', sort[0]?.[0] as string, suffix])
|
||||||
|
// }
|
||||||
|
|
||||||
|
// LIMIT (not working because of ORDER BY)
|
||||||
|
// const page: number = query?.page ?? 0
|
||||||
|
// const pageLimit: number | null = query?.limit ?? null
|
||||||
|
// let limit: number | null = null
|
||||||
|
// if (pageLimit && pageLimit > 0) {
|
||||||
|
// limit = pageLimit * (page + 1)
|
||||||
|
// req = req.concat(['LIMIT', limit.toString()])
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ALLOWW FILTERING
|
||||||
|
req = req.concat(['ALLOW', 'FILTERING'])
|
||||||
|
const client = (await Client.get())!
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(req, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: types.ResultSet | undefined
|
||||||
|
try {
|
||||||
|
res = await client.execute(req.join(' '), params)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error running request')
|
||||||
|
console.error(req, params)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!res) {
|
||||||
|
return {
|
||||||
|
rows: 0,
|
||||||
|
pageTotal: 0,
|
||||||
|
page: 1,
|
||||||
|
rowsTotal: 0,
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataset = res.rows
|
||||||
|
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
|
||||||
|
key,
|
||||||
|
value: this.dbToValue(key, obj.get(key))
|
||||||
|
})))
|
||||||
|
.map((obj) => {
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (Array.isArray(item) && !obj[key]) {
|
||||||
|
obj[key] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
.map((it) => this.schema.parse(it))
|
||||||
|
.filter((it): it is Implementation<T> => !!it)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST QUERY TREATMENT
|
||||||
|
*/
|
||||||
|
// if ((query?.where?.length ?? 0) > 0) {
|
||||||
|
// for (const it of query?.where ?? []) {
|
||||||
|
// // eslint-disable-next-line max-depth
|
||||||
|
// switch (it[1]) {
|
||||||
|
// case 'includes':
|
||||||
|
// dataset = dataset.filter((entry) => entry[it[0]]?.toString()?.includes(it[2]))
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// sort
|
||||||
|
// const sort = query?.$sort
|
||||||
|
// if (sort) {
|
||||||
|
// const sortKey = sort ? sort[0]![0] : objectFind(this.schema.model, (value) => {
|
||||||
|
// if (!isSchemaItem(value)) {
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
|
// return !!value.database?.created
|
||||||
|
// })
|
||||||
|
// const sortValue = sort ? sort[0]![1] : 'asc'
|
||||||
|
// if (sortKey && sortValue) {
|
||||||
|
// if (sortValue === 'asc') {
|
||||||
|
// dataset = dataset.sort((a, b) => b[sortKey as string]! > a[sortKey as string]! ? 1 : -1)
|
||||||
|
// } else {
|
||||||
|
// dataset = dataset.sort((a, b) => b[sortKey as string]! < a[sortKey as string]! ? 1 : -1)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log(res.rows, req)
|
||||||
|
// post request processing
|
||||||
|
// if (limit) {
|
||||||
|
// dataset = dataset.slice(page * (query?.limit ?? 0), limit)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// temp modification of comportement to use the new and better query system
|
||||||
|
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
||||||
|
// temp fix for the sorting algorithm
|
||||||
|
if (!query) {
|
||||||
|
// @ts-expect-error normal currently
|
||||||
|
query = { $sort: { created: Sort.DESC }}
|
||||||
|
} else {
|
||||||
|
query.$sort = { created: Sort.DESC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (query) {
|
||||||
|
dataset = filter(query, dataset, this.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(res)
|
||||||
|
return {
|
||||||
|
rows: res.rows.length,
|
||||||
|
rowsTotal: res.rowLength,
|
||||||
|
page: 1,
|
||||||
|
pageTotal: 1,
|
||||||
|
// page: page,
|
||||||
|
// pageTotal: pageLimit ? res.rowLength / pageLimit : 1,
|
||||||
|
data: dataset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(obj: Implementation<T>): Promise<Implementation<T> | null> {
|
||||||
|
return this.patch(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async patch(id: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
public async patch(id: string, obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public async patch(id: string | Partial<Implementation<T>>, obj?: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
|
||||||
|
if (!obj) {
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
obj = {...id} as Partial<Implementation<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the updated time
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (isSchemaItem(item) && item.database?.updated) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// build the request parts
|
||||||
|
const parts: Array<string> = ['UPDATE', this.table, 'SET']
|
||||||
|
const params: Array<any> = []
|
||||||
|
|
||||||
|
// remove ids
|
||||||
|
for (const tmp of this.id) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete obj[tmp]
|
||||||
|
}
|
||||||
|
|
||||||
|
// map the items to update
|
||||||
|
const keys = objectMap(obj as {}, (_, key) => `${key}=?`)
|
||||||
|
parts.push(keys.join(', '))
|
||||||
|
params.push(...objectValues(obj as {}))
|
||||||
|
|
||||||
|
// filter by the ids
|
||||||
|
parts.push('WHERE')
|
||||||
|
const read: Partial<any> = {}
|
||||||
|
for (let idx = 0; idx < this.id.length; idx++) {
|
||||||
|
const key = this.id[idx] as string
|
||||||
|
|
||||||
|
if (idx > 0) {
|
||||||
|
parts.push('AND')
|
||||||
|
}
|
||||||
|
parts.push(`${key}=?`)
|
||||||
|
const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
|
||||||
|
read[key] = this.valueToDB(key, value)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing id (${key})`)
|
||||||
|
}
|
||||||
|
params.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.partitionKeys && this.partitionKeys?.length > 0) {
|
||||||
|
const { data } = await this.read(read)
|
||||||
|
const item = data[0]
|
||||||
|
for (const key of this.partitionKeys) {
|
||||||
|
parts.push('AND', `${key as string}=?`)
|
||||||
|
params.push(this.valueToDB(key, item![key]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = parts.join(' ')
|
||||||
|
const client = await Client.get()
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(req, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client?.execute(req, params)
|
||||||
|
// console.log(res, req)
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log('post patch result', res, req)
|
||||||
|
}
|
||||||
|
return (await this.read(read)).data[0] ?? null
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e, req, params)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(obj: Implementation<T>): Promise<boolean> {
|
||||||
|
const parts = ['DELETE', 'FROM', this.table, 'WHERE']
|
||||||
|
const params: ArrayOrObject = []
|
||||||
|
|
||||||
|
objectLoop(obj as {}, (value, key, idx) => {
|
||||||
|
if (idx > 0) {
|
||||||
|
parts.push('AND')
|
||||||
|
}
|
||||||
|
parts.push(`${key}=?`)
|
||||||
|
params.push(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = await Client.get()
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(parts, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client!.execute(parts.join(' '), params)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e, parts, params)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueToDB(key: keyof T, value: any): string | number | boolean | Date {
|
||||||
|
const item = this.schema.model[key] as Item
|
||||||
|
const type = isSchemaItem(item) ? item.type : item
|
||||||
|
|
||||||
|
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private dbToValue(key: keyof T, value: string | number | boolean | Date): any {
|
||||||
|
const item = this.schema.model[key] as Item
|
||||||
|
const type = isSchemaItem(item) ? item.type : item
|
||||||
|
|
||||||
|
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
||||||
|
return JSON.parse(value as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
207
src/models/Adapters/FSAdapter.ts
Normal file
207
src/models/Adapters/FSAdapter.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import { objectLoop } from '@dzeio/object-util'
|
||||||
|
import archiver from 'archiver'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import file_system from 'fs'
|
||||||
|
import type DaoAdapter from '../DaoAdapter'
|
||||||
|
import type { DBPull } from '../DaoAdapter'
|
||||||
|
import { type Query } from '../Query'
|
||||||
|
import type Schema from '../Schema'
|
||||||
|
import { isSchemaItem, type Implementation, type Model } from '../Schema'
|
||||||
|
|
||||||
|
interface FS extends Model {
|
||||||
|
filename: StringConstructor
|
||||||
|
path: StringConstructor
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
data: BufferConstructor
|
||||||
|
type: StringConstructor
|
||||||
|
size: NumberConstructor
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||||
|
|
||||||
|
private id!: string
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly schema: Schema<T>,
|
||||||
|
public readonly basePath: string
|
||||||
|
) {
|
||||||
|
if (basePath.endsWith('/')) {
|
||||||
|
console.warn('the base path should not end wiath a "/", removing it')
|
||||||
|
basePath = basePath.slice(0, basePath.lastIndexOf('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make it clearer what it does
|
||||||
|
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
const realPath = this.getFullPath(obj.path!)
|
||||||
|
|
||||||
|
const finalFolder = realPath.slice(0, realPath.lastIndexOf('/'))
|
||||||
|
|
||||||
|
console.log('making the directory', finalFolder)
|
||||||
|
await fs.mkdir(finalFolder, { recursive: true })
|
||||||
|
|
||||||
|
if (obj.type === 'file') {
|
||||||
|
console.log('getting the data', finalFolder)
|
||||||
|
const data = obj.data
|
||||||
|
|
||||||
|
console.log('writing to', realPath)
|
||||||
|
if ((data as any) instanceof Buffer) {
|
||||||
|
await fs.writeFile(realPath, data as Buffer)
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(realPath, data as string)
|
||||||
|
}
|
||||||
|
return obj as Implementation<T>
|
||||||
|
} else {
|
||||||
|
console.log('making the final directory', realPath)
|
||||||
|
await fs.mkdir(realPath)
|
||||||
|
return obj as Implementation<T>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async createZippedBufferFromDirectory(directoryPath: string) {
|
||||||
|
const archive = archiver('zip', {zlib: {level: 9}})
|
||||||
|
archive.on('error', function(err) {
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
archive.on('warning', function(err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
console.log('warning: ', err)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const fileName = `${this.basePath}/zip/${directoryPath.split(this.basePath)[1]}.zip`
|
||||||
|
fs.mkdir(fileName.slice(0, fileName.lastIndexOf('/')), {recursive: true})
|
||||||
|
const output = file_system.createWriteStream(fileName)
|
||||||
|
archive.pipe(output)
|
||||||
|
archive.directory(directoryPath, false)
|
||||||
|
|
||||||
|
const timeout = (cb: (value: (value: unknown) => void) => void, interval: number) => () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => cb(resolve), interval)
|
||||||
|
})
|
||||||
|
const onTimeout = (seconds: number) => timeout((resolve) =>
|
||||||
|
resolve(`Timed out while zipping ${directoryPath}`), seconds * 1000)()
|
||||||
|
const error = await Promise.race([archive.finalize(), onTimeout(60)])
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
console.log('Error:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await fs.readFile(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public async read(query?: Query<Implementation<T>> | undefined, toZip?: boolean): Promise<DBPull<T>> {
|
||||||
|
|
||||||
|
const localPath = query?.path as string ?? ''
|
||||||
|
|
||||||
|
const realPath = this.getFullPath(localPath)
|
||||||
|
|
||||||
|
console.log('get the full path', realPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(realPath)
|
||||||
|
|
||||||
|
const files: Array<Implementation<T>> = []
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const dirFiles = await fs.readdir(realPath)
|
||||||
|
// eslint-disable-next-line max-depth
|
||||||
|
if (toZip === true) { // put queried file/folder in a zip file
|
||||||
|
const buffer = await this.createZippedBufferFromDirectory(realPath)
|
||||||
|
// eslint-disable-next-line max-depth
|
||||||
|
if (buffer !== null) {
|
||||||
|
files.push({
|
||||||
|
path: localPath,
|
||||||
|
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
|
||||||
|
data: buffer,
|
||||||
|
type: 'file',
|
||||||
|
size: buffer.length,
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
} else { // return every sub files
|
||||||
|
// eslint-disable-next-line max-depth
|
||||||
|
for await (const file of dirFiles) {
|
||||||
|
files.push(await this.readFile(localPath + '/' + file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
files.push(await this.readFile(localPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: files.length,
|
||||||
|
rowsTotal: files.length,
|
||||||
|
page: 0,
|
||||||
|
pageTotal: 1,
|
||||||
|
data: files
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
rows: 0,
|
||||||
|
rowsTotal: 0,
|
||||||
|
page: 0,
|
||||||
|
pageTotal: 0,
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(_obj: Implementation<T>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(_obj: Implementation<T>): Promise<boolean> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFullPath(localPath?: string): string {
|
||||||
|
if (localPath && !localPath?.startsWith('/')) {
|
||||||
|
console.warn('Your path should start with a "/", adding it')
|
||||||
|
localPath = ('/' + localPath) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
let realPath = this.basePath + (localPath ? localPath : '')
|
||||||
|
|
||||||
|
if (realPath.includes('\\')) {
|
||||||
|
realPath = realPath.replace(/\\/g, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return realPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readFile(localPath: string): Promise<Implementation<T>> {
|
||||||
|
|
||||||
|
const path = this.getFullPath(localPath)
|
||||||
|
console.log('reading file at', path)
|
||||||
|
const stats = await fs.stat(path)
|
||||||
|
const type = stats.isFile() ? 'file' : 'directory'
|
||||||
|
console.log('file is a', type)
|
||||||
|
|
||||||
|
const obj: Implementation<T> = {
|
||||||
|
path: localPath,
|
||||||
|
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
|
||||||
|
data: type === 'file' ? await fs.readFile(path) : '',
|
||||||
|
type: type,
|
||||||
|
size: stats.size
|
||||||
|
} as any
|
||||||
|
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (isSchemaItem(item) && item.database?.created) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = stats.ctime
|
||||||
|
} else if (isSchemaItem(item) && item.database?.updated) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = stats.mtime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
193
src/models/Adapters/LDAPAdapter.ts
Normal file
193
src/models/Adapters/LDAPAdapter.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import { objectClone, objectLoop, objectMap, objectRemap } from '@dzeio/object-util'
|
||||||
|
import ldap from 'ldapjs'
|
||||||
|
import type DaoAdapter from 'models/DaoAdapter'
|
||||||
|
import type { DBPull } from 'models/DaoAdapter'
|
||||||
|
import { type Query } from 'models/Query'
|
||||||
|
import Schema, { type Implementation, type Model } from 'models/Schema'
|
||||||
|
type LDAPFields = 'uid' | 'mail' | 'givenname' | 'sn' | 'jpegPhoto' | 'password'
|
||||||
|
|
||||||
|
export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||||
|
|
||||||
|
private reverseReference: Partial<Record<LDAPFields | string, keyof T>> = {}
|
||||||
|
private attributes: Array<LDAPFields | string> = []
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly schema: Schema<T>,
|
||||||
|
public readonly options: {
|
||||||
|
url: string
|
||||||
|
dnSuffix: string
|
||||||
|
adminUsername: string
|
||||||
|
adminPassword: string
|
||||||
|
fieldsCorrespondance?: Partial<Record<keyof T, LDAPFields | string>>
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
objectLoop(options.fieldsCorrespondance ?? {}, (value, key) => {
|
||||||
|
this.reverseReference[value] = key
|
||||||
|
this.attributes.push(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make it clearer what it does
|
||||||
|
public async create(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||||
|
const passwordField = this.options.fieldsCorrespondance?.password ?? 'password'
|
||||||
|
const doLogin = !!query?.[passwordField]
|
||||||
|
|
||||||
|
const emptyResult = {
|
||||||
|
rows: 0,
|
||||||
|
rowsTotal: 0,
|
||||||
|
page: 1,
|
||||||
|
pageTotal: 0,
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(await this.ldapFind({mail: 'f.bouillon@aptatio.com'}))
|
||||||
|
|
||||||
|
const userdn = objectMap(query, (value, key) => `${(this.options.fieldsCorrespondance as any)[key] ?? key}=${value}`)
|
||||||
|
?.filter((it) => it.slice(0, it.indexOf('=')) !== passwordField)
|
||||||
|
?.join(',')
|
||||||
|
if (!doLogin) {
|
||||||
|
const client = await this.bind(`cn=${this.options.adminUsername},${this.options.dnSuffix}`, this.options.adminPassword)
|
||||||
|
// @ts-expect-error nique ta mere
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const results = (await this.ldapFind(client, objectMap(query, (value, key) => [key as string, '', value as string])
|
||||||
|
.map((it) => ({key: it[0] as LDAPFields, value: it[2]}))!
|
||||||
|
)).map((it) => this.schema.parse(
|
||||||
|
objectRemap(it, (value, key) => ({key: this.reverseReference[key as string] as string, value: value}))
|
||||||
|
)).filter((it): it is Implementation<T> => !!it)
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: results.length,
|
||||||
|
rowsTotal: results.length,
|
||||||
|
page: 1,
|
||||||
|
pageTotal: 1,
|
||||||
|
data: results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clone = objectClone(query)
|
||||||
|
delete clone.password
|
||||||
|
const res = await this.read(clone)
|
||||||
|
const user = res.data[0]
|
||||||
|
if (!user) {
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
const password = query.password as string ?? ''
|
||||||
|
const client = await this.bind(`cn=${user[this.reverseReference.uid as keyof typeof user]!},${this.options.dnSuffix}`, password)
|
||||||
|
// @ts-expect-error nique x2
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const results = (await this.ldapFind(client, objectMap(clone, (value, key) => ({key: key as keyof LDAPFields, value: value}))
|
||||||
|
)).map((it) => this.schema.parse(
|
||||||
|
objectRemap(it, (value, key) => ({key: this.reverseReference[key as string] as string, value: value}))
|
||||||
|
)).filter((it): it is Implementation<T> => !!it)
|
||||||
|
|
||||||
|
if (results.length !== 1) {
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: results.length,
|
||||||
|
rowsTotal: results.length,
|
||||||
|
page: 1,
|
||||||
|
pageTotal: 1,
|
||||||
|
data: results
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.log('error, user not found', e)
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(_obj: Partial<Implementation<T>>): Promise<boolean> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
private bind(dn: string, password: string): Promise<ldap.Client> {
|
||||||
|
const client = ldap.createClient({
|
||||||
|
url: this.options.url
|
||||||
|
})
|
||||||
|
return new Promise<ldap.Client>((res, rej) => {
|
||||||
|
client.on('connect', () => {
|
||||||
|
client.bind(dn, password, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('error binding as', dn, err)
|
||||||
|
client.unbind()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('binded as', dn)
|
||||||
|
res(client)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.on('timeout', (err) => rej(err))
|
||||||
|
.on('connectTimeout', (err) => rej(err))
|
||||||
|
.on('error', (err) => rej(err))
|
||||||
|
.on('connectError', (err) => rej(err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ldapFind(client: ldap.Client, filters: Array<{key: LDAPFields, value: string}>): Promise<Array<Record<LDAPFields, string | Array<string> | undefined>>> {
|
||||||
|
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const firstFilter = filters.shift()!
|
||||||
|
return new Promise<Array<Record<LDAPFields, string | Array<string> | undefined>>>((res, rej) => {
|
||||||
|
const users: Array<Record<LDAPFields, string | Array<string> | undefined>> = []
|
||||||
|
client.search(
|
||||||
|
this.options.dnSuffix, {
|
||||||
|
filter: new ldap.EqualityFilter({
|
||||||
|
attribute: firstFilter.key as any,
|
||||||
|
value: firstFilter.value,
|
||||||
|
}),
|
||||||
|
scope: 'sub',
|
||||||
|
attributes: this.attributes
|
||||||
|
}, (err, search) => {
|
||||||
|
if (err) {
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
// console.log('search', search, err)
|
||||||
|
search.on('searchEntry', (entry) => {
|
||||||
|
users.push(this.parseUser(entry))
|
||||||
|
}).on('error', (err2) => {
|
||||||
|
rej(err2)
|
||||||
|
client.unbind()
|
||||||
|
console.error('error in search lol', err2)
|
||||||
|
}).on('end', () => {
|
||||||
|
console.log(users)
|
||||||
|
res(users)
|
||||||
|
|
||||||
|
client.unbind()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUser(usr: ldap.SearchEntry): Record<LDAPFields, string | Array<string> | undefined> {
|
||||||
|
const user: Record<string, string | Array<string> | undefined> = { dn: usr.objectName ?? undefined }
|
||||||
|
usr.attributes.forEach((attribute) => {
|
||||||
|
user[attribute.type] =
|
||||||
|
attribute.values.length === 1 ? attribute.values[0] : attribute.values
|
||||||
|
})
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
68
src/models/Adapters/MultiAdapter.ts
Normal file
68
src/models/Adapters/MultiAdapter.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import type DaoAdapter from 'models/DaoAdapter'
|
||||||
|
import Schema, { type Implementation, type Model } from 'models/Schema'
|
||||||
|
|
||||||
|
export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly schema: Schema<T>,
|
||||||
|
public readonly adapters: Array<{
|
||||||
|
adapter: DaoAdapter<Partial<T>>
|
||||||
|
fields: Array<keyof T>
|
||||||
|
/**
|
||||||
|
* a field from the main adapter that will backreference the child adapter
|
||||||
|
*/
|
||||||
|
childReference?: keyof T
|
||||||
|
}> = []
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// TODO: make it clearer what it does
|
||||||
|
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
let final: Implementation<T> = {} as any
|
||||||
|
// start by processing the childs
|
||||||
|
for (const adapter of this.adapters.sort((a) => a.childReference ? -1 : 1)) {
|
||||||
|
const partialObject: Partial<Implementation<T>> = {}
|
||||||
|
for (const key of adapter.fields) {
|
||||||
|
partialObject[key] = obj[key]
|
||||||
|
}
|
||||||
|
const res = await adapter.adapter.create!(partialObject as any)
|
||||||
|
if (res && adapter.childReference) {
|
||||||
|
obj[adapter.childReference] = res[adapter.childReference]
|
||||||
|
}
|
||||||
|
final = {...final, ...res}
|
||||||
|
}
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
// public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||||
|
// let final: Implementation<T> = {} as any
|
||||||
|
// // start by processing the childs
|
||||||
|
// for (const adapter of this.adapters.sort((a) => a.childReference ? -1 : 1)) {
|
||||||
|
// const partialObject: Partial<Implementation<T>> = {}
|
||||||
|
// for (const key of adapter.fields) {
|
||||||
|
// partialObject[key] = obj[key]
|
||||||
|
// }
|
||||||
|
// const res = await adapter.adapter.read!(query)
|
||||||
|
// if (res && adapter.childReference) {
|
||||||
|
// obj[adapter.childReference] = res[adapter.childReference]
|
||||||
|
// }
|
||||||
|
// final = {...final, ...res}
|
||||||
|
// }
|
||||||
|
// // step 2 merge elements
|
||||||
|
// return final
|
||||||
|
// }
|
||||||
|
|
||||||
|
public async update(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(_obj: Partial<Implementation<T>>): Promise<boolean> {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
412
src/models/Adapters/PostgresAdapter.ts
Normal file
412
src/models/Adapters/PostgresAdapter.ts
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
|
||||||
|
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import type { QueryResult } from 'pg'
|
||||||
|
import Client from '../Clients/PostgresClient'
|
||||||
|
import type DaoAdapter from '../DaoAdapter'
|
||||||
|
import type { DBPull } from '../DaoAdapter'
|
||||||
|
import { Sort, type Query } from '../Query'
|
||||||
|
import type Schema from '../Schema'
|
||||||
|
import { isSchemaItem, type Implementation, type Item, type Model } from '../Schema'
|
||||||
|
import { filter } from './AdapterUtils'
|
||||||
|
|
||||||
|
export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||||
|
|
||||||
|
private id!: Array<string>
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
/**
|
||||||
|
* the schema used by Cassandra
|
||||||
|
*/
|
||||||
|
public readonly schema: Schema<T>,
|
||||||
|
/**
|
||||||
|
* the table name
|
||||||
|
*/
|
||||||
|
public readonly table: string,
|
||||||
|
/**
|
||||||
|
* the id(s)
|
||||||
|
*/
|
||||||
|
id?: keyof T | Array<keyof T>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* other secondary keys necessary to update data
|
||||||
|
*/
|
||||||
|
private readonly partitionKeys?: Array<keyof T>,
|
||||||
|
/**
|
||||||
|
* additionnal options to make the adapter work
|
||||||
|
*/
|
||||||
|
private readonly options?: {
|
||||||
|
/**
|
||||||
|
* log the requests made to cassandra
|
||||||
|
*/
|
||||||
|
debug?: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!id) {
|
||||||
|
objectLoop(schema.model, (value, key) => {
|
||||||
|
if (!isSchemaItem(value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!value.database?.unique) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
id = key
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.id = typeof id === 'string' ? [id] : id as Array<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make it clearer what it does
|
||||||
|
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
console.log(obj)
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (isSchemaItem(item) && (item.database?.created || item.database?.updated)) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = new Date()
|
||||||
|
} else if (isSchemaItem(item) && item.database?.auto && !obj[key]) {
|
||||||
|
if (item.type === String) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = crypto.randomBytes(16).toString('hex')
|
||||||
|
} else {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = crypto.randomBytes(16).readUint32BE()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const clone = this.schema.parse(obj)
|
||||||
|
if (!clone) {
|
||||||
|
throw new Error('Invalid data given to create the final object')
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = objectKeys(clone)
|
||||||
|
const keysStr = keys.join(', ')
|
||||||
|
const values = keys.map((_, idx) => `$${idx+1}`).join(', ')
|
||||||
|
const req = `INSERT INTO ${this.table} (${keysStr}) VALUES (${values});`
|
||||||
|
const client = (await Client.get())!
|
||||||
|
|
||||||
|
const params = objectMap(clone as any, (value, key) => this.valueToDB(key as any, value))
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(req, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(req, params)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e, req, params)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.schema.parse(clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||||
|
let req: Array<string> = ['SELECT', '*', 'FROM', this.table]
|
||||||
|
|
||||||
|
// list of the differents items in the WHERE statement
|
||||||
|
const whereItems: Array<string> = []
|
||||||
|
// if ((query?.where?.length ?? 0) > 0 && (query?.where?.length !== 1 || query?.where?.[0]?.[1] !== 'includes')) {
|
||||||
|
// for (const it of query?.where ?? []) {
|
||||||
|
// // eslint-disable-next-line max-depth
|
||||||
|
// switch (it[1]) {
|
||||||
|
// case 'in':
|
||||||
|
// // eslint-disable-next-line no-case-declarations
|
||||||
|
// const arr = it[2] as Array<any>
|
||||||
|
|
||||||
|
// whereItems.push(`${String(it[0])} IN (${arr.map(() => '?').join(',')})`)
|
||||||
|
// params.push(...arr)
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case 'equal':
|
||||||
|
// whereItems.push(`${String(it[0])} = ?`)
|
||||||
|
// params.push(it[2])
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case 'after':
|
||||||
|
// whereItems.push(`${String(it[0])} >= ?`)
|
||||||
|
// params.push(it[2])
|
||||||
|
// break
|
||||||
|
|
||||||
|
// case 'before':
|
||||||
|
// whereItems.push(`${String(it[0])} <= ?`)
|
||||||
|
// params.push(it[2])
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (whereItems.length > 0) {
|
||||||
|
req.push('WHERE')
|
||||||
|
for (let idx = 0; idx < whereItems.length; idx++) {
|
||||||
|
const item = whereItems[idx] as string
|
||||||
|
if (idx > 0) {
|
||||||
|
req.push('AND')
|
||||||
|
}
|
||||||
|
req.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER BY (not working as we want :()
|
||||||
|
// const sort = query?.$sort
|
||||||
|
// if (sort && sort.length >= 1) {
|
||||||
|
// const suffix = sort[0]?.[1] === 'asc' ? 'ASC' : 'DESC'
|
||||||
|
// req = req.concat(['ORDER', 'BY', sort[0]?.[0] as string, suffix])
|
||||||
|
// }
|
||||||
|
|
||||||
|
// LIMIT (not working because of ORDER BY)
|
||||||
|
// const page: number = query?.page ?? 0
|
||||||
|
// const pageLimit: number | null = query?.limit ?? null
|
||||||
|
// let limit: number | null = null
|
||||||
|
// if (pageLimit && pageLimit > 0) {
|
||||||
|
// limit = pageLimit * (page + 1)
|
||||||
|
// req = req.concat(['LIMIT', limit.toString()])
|
||||||
|
// }
|
||||||
|
|
||||||
|
const client = (await Client.get())!
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: QueryResult<any> | undefined
|
||||||
|
try {
|
||||||
|
res = await client.query(`${req.join(' ')}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error running request')
|
||||||
|
console.error(req)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!res) {
|
||||||
|
return {
|
||||||
|
rows: 0,
|
||||||
|
pageTotal: 0,
|
||||||
|
page: 1,
|
||||||
|
rowsTotal: 0,
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataset = res.rows
|
||||||
|
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
|
||||||
|
key,
|
||||||
|
value: this.dbToValue(key, (obj as any)[key])
|
||||||
|
})))
|
||||||
|
.map((obj) => {
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (Array.isArray(item) && !obj[key]) {
|
||||||
|
obj[key] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
.map((it) => this.schema.parse(it))
|
||||||
|
.filter((it): it is Implementation<T> => !!it)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST QUERY TREATMENT
|
||||||
|
*/
|
||||||
|
// if ((query?.where?.length ?? 0) > 0) {
|
||||||
|
// for (const it of query?.where ?? []) {
|
||||||
|
// // eslint-disable-next-line max-depth
|
||||||
|
// switch (it[1]) {
|
||||||
|
// case 'includes':
|
||||||
|
// dataset = dataset.filter((entry) => entry[it[0]]?.toString()?.includes(it[2]))
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// sort
|
||||||
|
// const sort = query?.$sort
|
||||||
|
// if (sort) {
|
||||||
|
// const sortKey = sort ? sort[0]![0] : objectFind(this.schema.model, (value) => {
|
||||||
|
// if (!isSchemaItem(value)) {
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
|
// return !!value.database?.created
|
||||||
|
// })
|
||||||
|
// const sortValue = sort ? sort[0]![1] : 'asc'
|
||||||
|
// if (sortKey && sortValue) {
|
||||||
|
// if (sortValue === 'asc') {
|
||||||
|
// dataset = dataset.sort((a, b) => b[sortKey as string]! > a[sortKey as string]! ? 1 : -1)
|
||||||
|
// } else {
|
||||||
|
// dataset = dataset.sort((a, b) => b[sortKey as string]! < a[sortKey as string]! ? 1 : -1)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log(res.rows, req)
|
||||||
|
// post request processing
|
||||||
|
// if (limit) {
|
||||||
|
// dataset = dataset.slice(page * (query?.limit ?? 0), limit)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// temp modification of comportement to use the new and better query system
|
||||||
|
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
||||||
|
// temp fix for the sorting algorithm
|
||||||
|
if (!query) {
|
||||||
|
// @ts-expect-error normal currently
|
||||||
|
query = { $sort: { created: Sort.DESC }}
|
||||||
|
} else {
|
||||||
|
query.$sort = { created: Sort.DESC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (query) {
|
||||||
|
dataset = filter(query, dataset, this.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(res)
|
||||||
|
return {
|
||||||
|
rows: res.rowCount ?? 0,
|
||||||
|
rowsTotal: res.rowCount ?? 0,
|
||||||
|
page: 1,
|
||||||
|
pageTotal: 1,
|
||||||
|
// page: page,
|
||||||
|
// pageTotal: pageLimit ? res.rowLength / pageLimit : 1,
|
||||||
|
data: dataset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(obj: Implementation<T>): Promise<Implementation<T> | null> {
|
||||||
|
return this.patch(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async patch(id: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
public async patch(id: string, obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public async patch(id: string | Partial<Implementation<T>>, obj?: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||||
|
|
||||||
|
if (!obj) {
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
obj = {...id} as Partial<Implementation<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the updated time
|
||||||
|
objectLoop(this.schema.model, (item, key) => {
|
||||||
|
if (isSchemaItem(item) && item.database?.updated) {
|
||||||
|
// @ts-expect-error things get validated anyway
|
||||||
|
obj[key] = new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// build the request parts
|
||||||
|
const parts: Array<string> = ['UPDATE', this.table, 'SET']
|
||||||
|
const params: Array<any> = []
|
||||||
|
|
||||||
|
// remove ids
|
||||||
|
for (const tmp of this.id) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete obj[tmp]
|
||||||
|
}
|
||||||
|
|
||||||
|
// map the items to update
|
||||||
|
const keys = objectMap(obj as {}, (_, key) => `${key}=?`)
|
||||||
|
parts.push(keys.join(', '))
|
||||||
|
params.push(...objectValues(obj as {}))
|
||||||
|
|
||||||
|
// filter by the ids
|
||||||
|
parts.push('WHERE')
|
||||||
|
const read: Partial<any> = {}
|
||||||
|
for (let idx = 0; idx < this.id.length; idx++) {
|
||||||
|
const key = this.id[idx] as string
|
||||||
|
|
||||||
|
if (idx > 0) {
|
||||||
|
parts.push('AND')
|
||||||
|
}
|
||||||
|
parts.push(`${key}=?`)
|
||||||
|
const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
|
||||||
|
read[key] = this.valueToDB(key, value)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing id (${key})`)
|
||||||
|
}
|
||||||
|
params.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.partitionKeys && this.partitionKeys?.length > 0) {
|
||||||
|
const { data } = await this.read(read)
|
||||||
|
const item = data[0]
|
||||||
|
for (const key of this.partitionKeys) {
|
||||||
|
parts.push('AND', `${key as string}=${this.valueToDB(key, item![key])}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = parts.join(' ')
|
||||||
|
const client = await Client.get()
|
||||||
|
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(req, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client!.query(`${req}`)
|
||||||
|
// console.log(res, req)
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log('post patch result', res, req)
|
||||||
|
}
|
||||||
|
return (await this.read(read)).data[0] ?? null
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e, req, params)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(obj: Implementation<T>): Promise<boolean> {
|
||||||
|
const parts = ['DELETE', 'FROM', this.table, 'WHERE']
|
||||||
|
|
||||||
|
objectLoop(obj as {}, (value, key, idx) => {
|
||||||
|
if (idx > 0) {
|
||||||
|
parts.push('AND')
|
||||||
|
}
|
||||||
|
parts.push(`${key}=${value}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = await Client.get()
|
||||||
|
|
||||||
|
if (this.options?.debug) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client!.query(`${parts.join(' ')}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e, parts)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueToDB(key: keyof T, value: any): string | number | boolean | Date {
|
||||||
|
const item = this.schema.model[key] as Item
|
||||||
|
const type = isSchemaItem(item) ? item.type : item
|
||||||
|
|
||||||
|
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private dbToValue(key: keyof T, value: string | number | boolean | Date): any {
|
||||||
|
const item = this.schema.model[key] as Item
|
||||||
|
const type = isSchemaItem(item) ? item.type : item
|
||||||
|
|
||||||
|
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
||||||
|
return JSON.parse(value as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
91
src/models/Clients/PostgresClient.ts
Normal file
91
src/models/Clients/PostgresClient.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { wait } from 'libs/AsyncUtils'
|
||||||
|
import { getEnv, requireEnv } from 'libs/Env'
|
||||||
|
import pg from 'pg'
|
||||||
|
import Migration from '../Migrations'
|
||||||
|
const Postgres = pg.Client
|
||||||
|
|
||||||
|
export default class Client {
|
||||||
|
private static client?: pg.Client | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tri state value with
|
||||||
|
* -1 not started
|
||||||
|
* 0 migrating
|
||||||
|
* 1 migrated
|
||||||
|
*/
|
||||||
|
private static migrated = -1
|
||||||
|
/**
|
||||||
|
* get the connexion to cassandra, it will try until it succedeed
|
||||||
|
*/
|
||||||
|
public static async get(skipMigrations = false) {
|
||||||
|
while (this.migrated === 0 && !skipMigrations) {
|
||||||
|
await wait(100)
|
||||||
|
}
|
||||||
|
if (this.migrated === -1) {
|
||||||
|
await this.setup()
|
||||||
|
}
|
||||||
|
return this.client
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async require(skipMigrations = false) {
|
||||||
|
const client = await this.get(skipMigrations)
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Client not set but required')
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* connect to Cassandra
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public static async setup() {
|
||||||
|
if (this.migrated === 0) {
|
||||||
|
return this.migrated
|
||||||
|
}
|
||||||
|
if (!this.client || this.migrated === -1) {
|
||||||
|
this.migrated = 0
|
||||||
|
console.log('connecting to postgres')
|
||||||
|
|
||||||
|
this.client = new Postgres({
|
||||||
|
host: requireEnv('POSTGRES_HOST'),
|
||||||
|
user: requireEnv('POSTGRES_USERNAME'),
|
||||||
|
password: requireEnv('POSTGRES_PASSWORD'),
|
||||||
|
port: parseInt(getEnv('POSTGRES_PORT', '5432')),
|
||||||
|
database: requireEnv('POSTGRES_DATABASE', 'projectmanager'),
|
||||||
|
// debug(connection, query, parameters, paramTypes) {
|
||||||
|
// console.log(`${query}, ${parameters}`);
|
||||||
|
// },
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await this.client.connect()
|
||||||
|
} catch (e) {
|
||||||
|
this.client = null
|
||||||
|
this.migrated = -1
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('Error connecting to Postgres')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Migration.migrateToLatest()
|
||||||
|
} catch (e) {
|
||||||
|
this.migrated = -1
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('An error occured while migrating')
|
||||||
|
}
|
||||||
|
this.migrated = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isReady(): boolean {
|
||||||
|
if (this.migrated === -1) {
|
||||||
|
this.setup().catch(() => {/** empty result to not crash the app */})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this.migrated === 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,21 @@
|
|||||||
|
import { objectLoop, objectRemap } from '@dzeio/object-util'
|
||||||
|
import type DaoAdapter from './DaoAdapter'
|
||||||
|
import type { DBPull } from './DaoAdapter'
|
||||||
|
import { type Query } from './Query'
|
||||||
|
import type Schema from './Schema'
|
||||||
|
import { type Impl, type Implementation } from './Schema'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the Dao is the object that connect the Database or source to the application layer
|
* the Dao is the object that connect the Database or source to the application layer
|
||||||
*
|
*
|
||||||
* you MUST call it through the `DaoFactory` file
|
* you MUST call it through the `DaoFactory` file
|
||||||
*/
|
*/
|
||||||
export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
export default class Dao<S extends Schema<any>> {
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly schema: S,
|
||||||
|
public readonly adapter: DaoAdapter<S['model']>
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* insert a new object into the source
|
* insert a new object into the source
|
||||||
@ -11,7 +23,12 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param obj the object to create
|
* @param obj the object to create
|
||||||
* @returns the object with it's id filled if create or null otherwise
|
* @returns the object with it's id filled if create or null otherwise
|
||||||
*/
|
*/
|
||||||
abstract create(obj: Omit<Object, 'id' | 'created' | 'updated'>): Promise<Object | null>
|
public async create(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||||
|
if (!this.adapter.create) {
|
||||||
|
throw new Error('the Adapter does not allow you to create elements')
|
||||||
|
}
|
||||||
|
return this.adapter.create(obj)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* insert a new object into the source
|
* insert a new object into the source
|
||||||
@ -19,7 +36,9 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param obj the object to create
|
* @param obj the object to create
|
||||||
* @returns the object with it's id filled if create or null otherwise
|
* @returns the object with it's id filled if create or null otherwise
|
||||||
*/
|
*/
|
||||||
public insert: Dao<Object>['create'] = (obj: Parameters<Dao<Object>['create']>[0]) => this.create(obj)
|
public async insert(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||||
|
return this.create(obj as any)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* find the list of objects having elements from the query
|
* find the list of objects having elements from the query
|
||||||
@ -27,7 +46,13 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
|
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
|
||||||
* @returns an array containing the list of elements that match with the query
|
* @returns an array containing the list of elements that match with the query
|
||||||
*/
|
*/
|
||||||
abstract findAll(query?: Partial<Object>): Promise<Array<Object>>
|
// eslint-disable-next-line complexity
|
||||||
|
public async findAll(query?: Query<Impl<S>>, ...args: Array<any>): Promise<DBPull<S['model']>> {
|
||||||
|
if (!this.adapter.read) {
|
||||||
|
throw new Error('the Adapter does not allow you to read from the remote source')
|
||||||
|
}
|
||||||
|
return this.adapter.read(query as Query<Impl<S>>, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* find the list of objects having elements from the query
|
* find the list of objects having elements from the query
|
||||||
@ -35,18 +60,8 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
|
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
|
||||||
* @returns an array containing the list of elements that match with the query
|
* @returns an array containing the list of elements that match with the query
|
||||||
*/
|
*/
|
||||||
public find: Dao<Object>['findAll'] = (query: Parameters<Dao<Object>['findAll']>[0]) => this.findAll(query)
|
public async find(query: Parameters<this['findAll']>[0], ...args: Array<any>) {
|
||||||
|
return this.findAll(query, ...args)
|
||||||
/**
|
|
||||||
* find an object by it's id
|
|
||||||
*
|
|
||||||
* (shortcut to findOne({id: id}))
|
|
||||||
*
|
|
||||||
* @param id the id of the object
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public findById(id: Object['id']): Promise<Object | null> {
|
|
||||||
return this.findOne({id: id} as Partial<Object>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,7 +72,19 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param id the id of the object
|
* @param id the id of the object
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public get(id: Object['id']) {
|
public findById(id: any, ...args: Array<any>): Promise<Implementation<S['model']> | null> {
|
||||||
|
return this.findOne({id: id}, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find an object by it's id
|
||||||
|
*
|
||||||
|
* (shortcut to findOne({id: id}))
|
||||||
|
*
|
||||||
|
* @param id the id of the object
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public get(id: any) {
|
||||||
return this.findById(id)
|
return this.findById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,8 +94,8 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
|
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
|
||||||
* @returns the first element matching with the query or null otherwise
|
* @returns the first element matching with the query or null otherwise
|
||||||
*/
|
*/
|
||||||
public async findOne(query?: Partial<Object>): Promise<Object | null> {
|
public async findOne(query?: Parameters<this['findAll']>[0], ...args: Array<any>): Promise<Implementation<S['model']> | null> {
|
||||||
return (await this.findAll(query))[0] ?? null
|
return (await this.findAll(query, ...args)).data[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,37 +106,51 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
|
|||||||
* @param obj the object to update
|
* @param obj the object to update
|
||||||
* @returns an object if it was able to update or null otherwise
|
* @returns an object if it was able to update or null otherwise
|
||||||
*/
|
*/
|
||||||
abstract update(obj: Object): Promise<Object | null>
|
public async update(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||||
|
if (!this.adapter.update) {
|
||||||
|
throw new Error('the Adapter does not allow you to update to the remote source')
|
||||||
|
}
|
||||||
|
return this.adapter.update(obj)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* change some elements from the object and return the object updated
|
* change some elements from the object and return the object updated
|
||||||
* @param id the id of the object
|
* @param id the id of the object
|
||||||
* @param changegs the change to make
|
* @param changes the change to make
|
||||||
*/
|
*/
|
||||||
public async patch(id: string, changes: Partial<Object>): Promise<Object | null> {
|
public async patch(id: string, changes: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||||
const query = await this.findById(id)
|
if (!this.adapter.patch) {
|
||||||
if (!query) {
|
const query = await this.findById(id)
|
||||||
return null
|
if (!query) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this.update({...query, ...changes})
|
||||||
}
|
}
|
||||||
return await this.update({...query, ...changes})
|
return await this.adapter.patch(id, changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* update the remote reference of the object or create it if not found
|
* update the remote reference of the object or create it if not found
|
||||||
* @param obj the object to update/insert
|
* @param obj the object to update/insert
|
||||||
* @returns the object is updated/inserted or null otherwise
|
* @returns the object is updated/inserted or null otherwise
|
||||||
*/
|
*/
|
||||||
public async upsert(object: Object | Omit<Object, 'id' | 'created' | 'updated'>): Promise<Object | null> {
|
public async upsert(object: Partial<Implementation<S['model']>>): Promise<Partial<Implementation<S['model']>> | null> {
|
||||||
if ('id' in object) {
|
if (!this.adapter.upsert) {
|
||||||
return this.update(object)
|
throw new Error('the Adapter does not allow you to upsert to the remote source')
|
||||||
}
|
}
|
||||||
return this.insert(object)
|
return this.adapter.upsert(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the object
|
* Delete the object
|
||||||
* @param obj the object to delete
|
* @param obj the object or ID to delete
|
||||||
*
|
*
|
||||||
* @returns if the object was deleted or not (if object is not in db it will return true)
|
* @returns if the object was deleted or not (if object is not in db it will return true)
|
||||||
*/
|
*/
|
||||||
abstract delete(obj: Object): Promise<boolean>
|
public async delete(obj: Partial<Implementation<S['model']>>): Promise<boolean> {
|
||||||
|
if (!this.adapter.delete) {
|
||||||
|
throw new Error('the Adapter does not allow you to delete on the remote source')
|
||||||
|
}
|
||||||
|
return this.adapter.delete(obj)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
61
src/models/DaoAdapter.ts
Normal file
61
src/models/DaoAdapter.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { type Query } from './Query'
|
||||||
|
import type { Implementation, Model } from './Schema'
|
||||||
|
|
||||||
|
export interface DBPull<T extends Model> {
|
||||||
|
rows: number
|
||||||
|
rowsTotal: number
|
||||||
|
page: number
|
||||||
|
pageTotal: number
|
||||||
|
|
||||||
|
data: Array<Implementation<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the Dao is the object that connect the Database or source to the application layer
|
||||||
|
*
|
||||||
|
* you MUST call it through the `DaoFactory` file
|
||||||
|
*/
|
||||||
|
export default interface DaoAdapter<T extends Model> {
|
||||||
|
/**
|
||||||
|
* create a new object in the remote source
|
||||||
|
*
|
||||||
|
* @param obj the object to create
|
||||||
|
*/
|
||||||
|
create?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* read from the remote source
|
||||||
|
*
|
||||||
|
* @param query the query to filter/sort results
|
||||||
|
*/
|
||||||
|
read?(query?: Query<Implementation<T>>, ...args: any): Promise<DBPull<T>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update an object to the remote source
|
||||||
|
*
|
||||||
|
* @param obj the object to update
|
||||||
|
*/
|
||||||
|
update?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* depending if the object already exists or not
|
||||||
|
* it will update an exisintg object or create a new one
|
||||||
|
*
|
||||||
|
* @param obj the object to insert/update
|
||||||
|
*/
|
||||||
|
upsert?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update an object to the remote source
|
||||||
|
*
|
||||||
|
* @param id (DEPRECATED) the ID of the object
|
||||||
|
* @param obj the object to patch (MUST include ids, and changes)
|
||||||
|
*/
|
||||||
|
patch?(id: string, obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete an object from the source
|
||||||
|
* @param obj the object ot delete (it must at least include the id(s))
|
||||||
|
*/
|
||||||
|
delete?(obj: Partial<Implementation<T>>): Promise<boolean>
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
/**
|
import PostgresAdapter from './Adapters/PostgresAdapter'
|
||||||
* TODO:
|
import Dao from './Dao'
|
||||||
* Add to `DaoItem` your model name
|
import Issue from './Issue'
|
||||||
* Add to the function `initDao` the Dao
|
import Project from './Project'
|
||||||
*/
|
import State from './State'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the different Daos that can be initialized
|
* the different Daos that can be initialized
|
||||||
@ -10,11 +10,15 @@
|
|||||||
* Touch this interface to define which key is linked to which Dao
|
* Touch this interface to define which key is linked to which Dao
|
||||||
*/
|
*/
|
||||||
interface DaoItem {
|
interface DaoItem {
|
||||||
|
project: Dao<typeof Project>
|
||||||
|
issue: Dao<typeof Issue>
|
||||||
|
state: Dao<typeof State>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class to get any DAO
|
* Class to get any DAO
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||||
export default class DaoFactory {
|
export default class DaoFactory {
|
||||||
/**
|
/**
|
||||||
* reference of the different Daos for a correct singleton implementation
|
* reference of the different Daos for a correct singleton implementation
|
||||||
@ -46,8 +50,12 @@ export default class DaoFactory {
|
|||||||
* @param item the element to init
|
* @param item the element to init
|
||||||
* @returns a new initialized dao or undefined if no dao is linked
|
* @returns a new initialized dao or undefined if no dao is linked
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
private static initDao(item: keyof DaoItem): any | undefined {
|
private static initDao(item: keyof DaoItem): any | undefined {
|
||||||
switch (item) {
|
switch (item) {
|
||||||
|
case 'project': return new Dao(Project, new PostgresAdapter(Project, 'project', 'id'))
|
||||||
|
case 'issue': return new Dao(Issue, new PostgresAdapter(Issue, 'issue', 'id'))
|
||||||
|
case 'state': return new Dao(State, new PostgresAdapter(State, 'state', 'id'))
|
||||||
default: return undefined
|
default: return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
49
src/models/Issue/index.ts
Normal file
49
src/models/Issue/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import Schema, { type Impl } from 'models/Schema'
|
||||||
|
|
||||||
|
export enum Priority {
|
||||||
|
None,
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Urgent
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
/**
|
||||||
|
* the project ID
|
||||||
|
*/
|
||||||
|
id: {type: String, database: {unique: true, index: true, auto: true}},
|
||||||
|
localid: Number,
|
||||||
|
project: String,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the email the project was created from
|
||||||
|
*/
|
||||||
|
name: { type: String, nullable: true },
|
||||||
|
|
||||||
|
description: { type: String, nullable: true },
|
||||||
|
|
||||||
|
state: String, //state id
|
||||||
|
priority: {type: Number, defaultValue: Priority.None},
|
||||||
|
begin: { type: Date, nullable: true },
|
||||||
|
due: { type: Date, nullable: true },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent issue
|
||||||
|
*/
|
||||||
|
parent: { type: String, nullable: true },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* issue labels
|
||||||
|
*/
|
||||||
|
labels: [String],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is the issue archived
|
||||||
|
*/
|
||||||
|
archived: { type: Boolean, defaultValue: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default schema
|
||||||
|
|
||||||
|
export type ProjectObj = Impl<typeof schema>
|
22
src/models/Migrations/0.ts
Normal file
22
src/models/Migrations/0.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { type MigrationObj } from '.'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
date: new Date(0),
|
||||||
|
async up(client): Promise<boolean> {
|
||||||
|
await client.query(`CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);`)
|
||||||
|
|
||||||
|
await client.query(`INSERT INTO settings (id, value) VALUES ('db_created', ${new Date().getTime().toString()})`)
|
||||||
|
await client.query(`INSERT INTO settings (id, value) VALUES ('db_version', -1)`)
|
||||||
|
await client.query(`UPDATE settings SET value = ${new Date().getTime().toString()} WHERE id = 'db_created';`)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async down(client) {
|
||||||
|
await client.query(`DROP TABLE settings`)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
} as MigrationObj
|
46
src/models/Migrations/2024-05-15T9:57:48.ts
Normal file
46
src/models/Migrations/2024-05-15T9:57:48.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { type MigrationObj } from '.'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
date: new Date('2024-05-15T09:57:48'),
|
||||||
|
async up(client): Promise<boolean> {
|
||||||
|
await client.query(`CREATE TABLE IF NOT EXISTS project (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
displayID TEXT,
|
||||||
|
visibility BOOL,
|
||||||
|
archived BOOL
|
||||||
|
);`)
|
||||||
|
|
||||||
|
await client.query(`CREATE TABLE IF NOT EXISTS state (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
color CHAR(6),
|
||||||
|
preset BOOL
|
||||||
|
)`)
|
||||||
|
|
||||||
|
await client.query(`CREATE TABLE IF NOT EXISTS issue (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
localID INT,
|
||||||
|
project TEXT,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
state TEXT,
|
||||||
|
priority INT,
|
||||||
|
begin DATE,
|
||||||
|
due DATE,
|
||||||
|
parent TEXT,
|
||||||
|
labels TEXT[],
|
||||||
|
archived BOOL
|
||||||
|
)`)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async down(client) {
|
||||||
|
await client.query(`DROP TABLE project`)
|
||||||
|
await client.query(`DROP TABLE state`)
|
||||||
|
await client.query(`DROP TABLE issue`)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
} as MigrationObj
|
31
src/models/Migrations/Example.ts
Normal file
31
src/models/Migrations/Example.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { Client } from 'cassandra-driver'
|
||||||
|
import { type MigrationObj } from '.'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/** SET THE DATE IN ISO FORMAT HERE */
|
||||||
|
date: new Date('2024-03-26T11:55:28'),
|
||||||
|
async up(client: Client): Promise<boolean> {
|
||||||
|
const requests: Array<string> = [
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
for await (const request of requests) {
|
||||||
|
await client.execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async down(client: Client) {
|
||||||
|
const requests: Array<string> = [
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
for await (const request of requests) {
|
||||||
|
try {
|
||||||
|
await client.execute(request)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
} as MigrationObj
|
98
src/models/Migrations/index.ts
Normal file
98
src/models/Migrations/index.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import Client from 'models/Clients/PostgresClient'
|
||||||
|
import type { Client as Postgres } from 'pg'
|
||||||
|
import migration0 from './0'
|
||||||
|
import migration20240515T95748 from './2024-05-15T9:57:48'
|
||||||
|
|
||||||
|
export interface MigrationObj {
|
||||||
|
date: Date
|
||||||
|
up(client: Postgres): Promise<boolean>
|
||||||
|
down?(client: Postgres): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default abstract class CassandraMigrations {
|
||||||
|
public abstract version: number
|
||||||
|
|
||||||
|
public static async getVersion(): Promise<Date | null> {
|
||||||
|
const client = await Client.require(true)
|
||||||
|
try {
|
||||||
|
const res = await client.query(`SELECT value FROM settings WHERE id = 'db_version'`)
|
||||||
|
const date = new Date(res.rows[0]?.value)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return date
|
||||||
|
} catch (e) {
|
||||||
|
// table does not exists
|
||||||
|
console.log('Settings table does not exists', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate the database to the latest version
|
||||||
|
*/
|
||||||
|
public static async migrateToLatest() {
|
||||||
|
const migrations = this.getMigrations()
|
||||||
|
const latest = migrations[migrations.length - 1]
|
||||||
|
return await this.migrateTo(latest!.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* migrate to a specific date in time
|
||||||
|
* @param date the date to try to migrate to
|
||||||
|
*/
|
||||||
|
public static async migrateTo(date: number | Date) {
|
||||||
|
const client = await Client.require(true)
|
||||||
|
|
||||||
|
if (typeof date === 'number') {
|
||||||
|
date = new Date(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = await this.getVersion()
|
||||||
|
|
||||||
|
const migrations = this.getMigrations()
|
||||||
|
|
||||||
|
const time = !version ? -1 : version.getTime()
|
||||||
|
|
||||||
|
// same version, don't to anything
|
||||||
|
if (date.getTime() === time) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('Current DB version', version)
|
||||||
|
|
||||||
|
// run up migrations
|
||||||
|
if (time < date.getTime()) {
|
||||||
|
console.log('Migrating up to', date)
|
||||||
|
const migrationsToRun = migrations.filter((it) => it.date.getTime() > time && it.date.getTime() <= (date as Date).getTime())
|
||||||
|
for (const migration of migrationsToRun) {
|
||||||
|
console.log('Migrating from', version, 'to', migration.date)
|
||||||
|
await migration.up(client)
|
||||||
|
await this.setVersion(migration.date)
|
||||||
|
version = migration.date
|
||||||
|
}
|
||||||
|
} else { // run down migrations
|
||||||
|
console.log('Migrating down to', date)
|
||||||
|
const migrationsToRun = migrations.filter((it) => it.date.getTime() <= time && it.date.getTime() >= (date as Date).getTime())
|
||||||
|
.toReversed()
|
||||||
|
for (const migration of migrationsToRun) {
|
||||||
|
console.log('Migrating from', version, 'to', migration.date)
|
||||||
|
await migration.down?.(client)
|
||||||
|
await this.setVersion(migration.date)
|
||||||
|
version = migration.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Done migrating')
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async setVersion(version: Date) {
|
||||||
|
const client = await Client.require(true)
|
||||||
|
await client.query(`UPDATE settings SET value = $1 WHERE id = 'db_version';`, [version.toISOString()])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getMigrations() {
|
||||||
|
return [
|
||||||
|
migration0,
|
||||||
|
migration20240515T95748
|
||||||
|
].sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||||
|
}
|
||||||
|
}
|
22
src/models/Project/index.ts
Normal file
22
src/models/Project/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Schema, { type Impl } from 'models/Schema'
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
/**
|
||||||
|
* the project ID
|
||||||
|
*/
|
||||||
|
id: {type: String, database: {unique: true, index: true, auto: true}},
|
||||||
|
/**
|
||||||
|
* the email the project was created from
|
||||||
|
*/
|
||||||
|
name: { type: String, nullable: true },
|
||||||
|
|
||||||
|
description: { type: String, nullable: true },
|
||||||
|
|
||||||
|
displayID: { type: String, nullable: true },
|
||||||
|
visibility: { type: String, nullable: true },
|
||||||
|
archived: { type: Boolean, defaultValue: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default schema
|
||||||
|
|
||||||
|
export type ProjectObj = Impl<typeof schema>
|
149
src/models/Query.ts
Normal file
149
src/models/Query.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
interface QueryRootFilters<Obj extends Record<string, any>> {
|
||||||
|
/**
|
||||||
|
* one of the results should be true to be true
|
||||||
|
*/
|
||||||
|
$or?: Array<QueryList<Obj>>
|
||||||
|
/**
|
||||||
|
* every results should be false to be true
|
||||||
|
*/
|
||||||
|
$nor?: Array<QueryList<Obj>>
|
||||||
|
/**
|
||||||
|
* (default) make sure every sub queries return true
|
||||||
|
*/
|
||||||
|
$and?: Array<QueryList<Obj>>
|
||||||
|
/**
|
||||||
|
* at least one result must be false
|
||||||
|
*/
|
||||||
|
$nand?: Array<QueryList<Obj>>
|
||||||
|
/**
|
||||||
|
* invert the result from the following query
|
||||||
|
*/
|
||||||
|
$not?: QueryList<Obj>
|
||||||
|
/**
|
||||||
|
* define a precise offset of the data you fetched
|
||||||
|
*/
|
||||||
|
$offset?: number
|
||||||
|
/**
|
||||||
|
* limit the number of elements returned from the dataset
|
||||||
|
*/
|
||||||
|
$limit?: number
|
||||||
|
/**
|
||||||
|
* sort the data the way you want with each keys being priorized
|
||||||
|
*
|
||||||
|
* ex:
|
||||||
|
* {a: Sort.DESC, b: Sort.ASC}
|
||||||
|
*
|
||||||
|
* will sort first by a and if equal will sort by b
|
||||||
|
*/
|
||||||
|
$sort?: SortInterface<Obj>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logical operators that can be used to filter data
|
||||||
|
*/
|
||||||
|
export type QueryLogicalOperator<Value> = {
|
||||||
|
/**
|
||||||
|
* one of the results should be true to be true
|
||||||
|
*/
|
||||||
|
$or: Array<QueryValues<Value>>
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* every results should be false to be true
|
||||||
|
*/
|
||||||
|
$nor: Array<QueryValues<Value>>
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* at least one result must be false
|
||||||
|
*/
|
||||||
|
$nand: Array<QueryValues<Value>>
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* (default) make sure every sub queries return true
|
||||||
|
*/
|
||||||
|
$and: Array<QueryValues<Value>>
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* invert the result from the following query
|
||||||
|
*/
|
||||||
|
$not: QueryValues<Value>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* differents comparisons operators that can be used to filter data
|
||||||
|
*/
|
||||||
|
export type QueryComparisonOperator<Value> = {
|
||||||
|
/**
|
||||||
|
* the remote source value must be absolutelly equal to the proposed value
|
||||||
|
*/
|
||||||
|
$eq: Value | null
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* the remote source value must be greater than the proposed value
|
||||||
|
*/
|
||||||
|
$gt: number | Date
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* the remote source value must be lesser than the proposed value
|
||||||
|
*/
|
||||||
|
$lt: number | Date
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* the remote source value must be greater or equal than the proposed value
|
||||||
|
*/
|
||||||
|
$gte: number | Date
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* the remote source value must be lesser or equal than the proposed value
|
||||||
|
*/
|
||||||
|
$lte: number | Date
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* the remote source value must be one of the proposed values
|
||||||
|
*/
|
||||||
|
$in: Array<Value>
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* (for string only) part of the proposed value must be in the remote source
|
||||||
|
*/
|
||||||
|
$inc: Value | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryList<Obj extends Record<string, any>> = {
|
||||||
|
[Key in keyof Obj]?: QueryValues<Obj[Key]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Differents values the element can take
|
||||||
|
* if null it will check if it is NULL on the remote
|
||||||
|
* if array it will check oneOf
|
||||||
|
* if RegExp it will check if regexp match
|
||||||
|
*/
|
||||||
|
export type QueryValues<Value> = Value |
|
||||||
|
null |
|
||||||
|
Array<Value> |
|
||||||
|
RegExp |
|
||||||
|
QueryComparisonOperator<Value> |
|
||||||
|
QueryLogicalOperator<Value>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query element that allows you to query different elements
|
||||||
|
*/
|
||||||
|
export type Query<Obj extends Record<string, any>> = QueryList<Obj> & QueryRootFilters<Obj>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sorting interface with priority
|
||||||
|
*/
|
||||||
|
export type SortInterface<Obj extends Record<string, any>> = {
|
||||||
|
[Key in keyof Obj]?: Sort
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Sort {
|
||||||
|
/**
|
||||||
|
* Sort the values from the lowest to the largest
|
||||||
|
*/
|
||||||
|
ASC,
|
||||||
|
/**
|
||||||
|
* Sort the values form the largest to the lowest
|
||||||
|
*/
|
||||||
|
DESC
|
||||||
|
}
|
588
src/models/Schema.ts
Normal file
588
src/models/Schema.ts
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
/* eslint-disable max-depth */
|
||||||
|
import { isObject, objectClean, objectKeys, objectLoop, objectRemap } from '@dzeio/object-util'
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
export type DBBaseType = StringConstructor | BooleanConstructor | NumberConstructor | DateConstructor | BufferConstructor | File
|
||||||
|
|
||||||
|
// Advanced Schema item
|
||||||
|
export interface SchemaItem<T extends DBBaseType = DBBaseType> {
|
||||||
|
/**
|
||||||
|
* the field type
|
||||||
|
*/
|
||||||
|
type: T
|
||||||
|
|
||||||
|
// TODO: Infer the value type based on the `type`
|
||||||
|
/**
|
||||||
|
* set a default value when creating the row
|
||||||
|
*/
|
||||||
|
defaultValue?: Infer<T> | (() => Infer<T>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parameters related to database management
|
||||||
|
*/
|
||||||
|
database?: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* this field is filled automatically depending on it's type to a random unique value
|
||||||
|
*/
|
||||||
|
auto?: 'uuid' | true
|
||||||
|
/**
|
||||||
|
* indicate that this field is a date field that is updated for each DB updates
|
||||||
|
*/
|
||||||
|
updated?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* value set when it is created in the DB
|
||||||
|
*/
|
||||||
|
created?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* must the field be unique in the DB?
|
||||||
|
*/
|
||||||
|
unique?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is the field indexed in the DB (if supported by the DB)
|
||||||
|
*/
|
||||||
|
index?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is the field nullable (allow additionnal values like null, undefined, '')
|
||||||
|
*/
|
||||||
|
nullable?: boolean
|
||||||
|
|
||||||
|
/***************************
|
||||||
|
* Number specific filters *
|
||||||
|
***************************/
|
||||||
|
/**
|
||||||
|
* minimum value of the field
|
||||||
|
*/
|
||||||
|
min?: number
|
||||||
|
/**
|
||||||
|
* maximum value of the field
|
||||||
|
*/
|
||||||
|
max?: number
|
||||||
|
|
||||||
|
/***************************
|
||||||
|
* String specific filters *
|
||||||
|
***************************/
|
||||||
|
/**
|
||||||
|
* minimum length of the field
|
||||||
|
*/
|
||||||
|
minLength?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maximum length of the field
|
||||||
|
*/
|
||||||
|
maxLength?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the field must correspond to the Regex below
|
||||||
|
*/
|
||||||
|
regex?: RegExp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the value MUST be in the corresponding array just like an ENUM
|
||||||
|
*/
|
||||||
|
choices?: Array<Infer<T>>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// the possible types an item can have as a type
|
||||||
|
type Type = DBBaseType | SchemaItem
|
||||||
|
|
||||||
|
// an item
|
||||||
|
export type Item = Type | Array<Type> | Record<string, Type> | undefined
|
||||||
|
|
||||||
|
// the schema
|
||||||
|
export type Model = Record<string, Item>
|
||||||
|
|
||||||
|
// decode the schema
|
||||||
|
type DecodeSchemaItem<T> = T extends SchemaItem ? T['type'] : T
|
||||||
|
|
||||||
|
// infer the correct type based on the final item
|
||||||
|
type StringInfer<T> = T extends StringConstructor ? string : T
|
||||||
|
type NumberInfer<T> = T extends NumberConstructor ? number : T
|
||||||
|
type BooleanInfer<T> = T extends BooleanConstructor ? boolean : T
|
||||||
|
type DateInfer<T> = T extends DateConstructor ? Date : T
|
||||||
|
type BufferInfer<T> = T extends BufferConstructor ? Buffer : T
|
||||||
|
|
||||||
|
|
||||||
|
// @ts-expect-error fuck you
|
||||||
|
type GetSchema<T extends DBBaseType | SchemaItem> = T extends SchemaItem ? T : SchemaItem<T>
|
||||||
|
|
||||||
|
type ToSchemaItem<T extends DBBaseType | SchemaItem> = GetSchema<T>
|
||||||
|
// @ts-expect-error fuck you
|
||||||
|
export type SchemaToValue<T extends SchemaItem> = Infer<T['type']> | (T['validation']['nullable'] extends true ? (T['defaultValue'] extends Infer<T['type']> ? Infer<T['type']> : undefined) : Infer<T['type']>)
|
||||||
|
|
||||||
|
// more advanced types infers
|
||||||
|
type RecordInfer<T> = T extends Record<string, Type> ? Implementation<T> : T
|
||||||
|
type ArrayInfer<T> = T extends Array<Type> ? Array<Infer<T[0]>> : T
|
||||||
|
|
||||||
|
// Infer the final type for each elements
|
||||||
|
type Infer<T> = ArrayInfer<RecordInfer<StringInfer<NumberInfer<BooleanInfer<DateInfer<BufferInfer<DecodeSchemaItem<T>>>>>>>>
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
|
||||||
|
// @ts-ignore fuck you
|
||||||
|
type ImplementationItem<I extends Item> = I extends (DBBaseType | SchemaItem) ? SchemaToValue<ToSchemaItem<I>> : Infer<I>
|
||||||
|
|
||||||
|
// the final implementation for the front user
|
||||||
|
export type Implementation<S extends Model> = {
|
||||||
|
[key in keyof S]: ImplementationItem<S[key]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shortcut type for model implementation
|
||||||
|
*/
|
||||||
|
export type Impl<S extends Schema<any>> = Implementation<S['model']>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* validate a SchemaItem
|
||||||
|
* @param value the object to validate
|
||||||
|
* @returns if the object is a SchemaItem or not
|
||||||
|
*/
|
||||||
|
export function isSchemaItem(value: any): value is SchemaItem {
|
||||||
|
if (!isObject(value) || !('type' in value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = value.type
|
||||||
|
|
||||||
|
if (typeof type !== 'function') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add more checks
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Schema<M extends Model> {
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly model: M,
|
||||||
|
private options?: {
|
||||||
|
/**
|
||||||
|
* show debug informations
|
||||||
|
*/
|
||||||
|
debug?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* force the Schema parser to shut up (even warnings!!!)
|
||||||
|
*/
|
||||||
|
quiet?: boolean
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* validate the data imported
|
||||||
|
*
|
||||||
|
* WARNING: no modifications are done to the `data` object so `defaultValue` is not applied
|
||||||
|
* if you need it applied use `parse`
|
||||||
|
*
|
||||||
|
* @param data the data to validate
|
||||||
|
* @returns if data is compatible with the schema or not
|
||||||
|
*/
|
||||||
|
public validate(data: any): data is Implementation<M> {
|
||||||
|
if (!isObject(data)) {
|
||||||
|
this.log(data, 'is not an object')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate that the data length is the same as the model length
|
||||||
|
const modelKeys = objectKeys(this.model)
|
||||||
|
const dataKeys = objectKeys(data)
|
||||||
|
if (dataKeys.length > modelKeys.length) {
|
||||||
|
this.log('It seems there is an excess amount of items in the data, unknown keys:', dataKeys.filter((key) => !modelKeys.includes(key as string)))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectLoop(this.model, (item, field) => {
|
||||||
|
this.log('Validating', field)
|
||||||
|
return this.validateItem(item, data[field])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse an object to be exactly compatible with the schema
|
||||||
|
* @param input the input object
|
||||||
|
* @returns an object containin ONLY the elements defined by the schema
|
||||||
|
*/
|
||||||
|
public parse(input: any): Implementation<M> | null {
|
||||||
|
console.log(input)
|
||||||
|
if (!isObject(input)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Implementation<M> = {} as any
|
||||||
|
const res = objectLoop(this.model, (item, field) => {
|
||||||
|
|
||||||
|
const value = input[field]
|
||||||
|
|
||||||
|
try {
|
||||||
|
(data as any)[field] = this.parseItem(item, value)
|
||||||
|
} catch (e) {
|
||||||
|
this.warn(`the field "${field}" could not validate against "${value}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// console.log('result', res)
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseQuery(query: URLSearchParams): Implementation<M> | null {
|
||||||
|
const data: Implementation<M> = {} as any
|
||||||
|
const res = objectLoop(this.model, (item, field) => {
|
||||||
|
|
||||||
|
// TODO: Handle search query arrays
|
||||||
|
const value = query.get(field)
|
||||||
|
|
||||||
|
try {
|
||||||
|
(data as any)[field] = this.parseItem(item, value)
|
||||||
|
} catch {
|
||||||
|
this.warn(`the field ${field} could not validate against ${value}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// console.log('result', res)
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseForm(form: HTMLFormElement): Implementation<M> | null {
|
||||||
|
const data: Implementation<M> = {} as any
|
||||||
|
const res = objectLoop(this.model, (item, field) => {
|
||||||
|
// if (Array.isArray(item) || typeof item === 'object' && !isSchemaItem(item)) {
|
||||||
|
// this.warn('idk what this check does')
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
|
|
||||||
|
const value = form[field]?.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
(data as any)[field] = this.parseItem(item, value)
|
||||||
|
} catch {
|
||||||
|
this.warn(`the field ${field} could not validate against ${value}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// console.log('result', res)
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseFormData(form: FormData): Implementation<M> | null {
|
||||||
|
const data: Implementation<M> = {} as any
|
||||||
|
const res = objectLoop(this.model, (item, field) => {
|
||||||
|
// if (Array.isArray(item) || typeof item === 'object' && !isSchemaItem(item)) {
|
||||||
|
// this.warn('idk what this check does')
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
|
const value = form.get(field) ?? null
|
||||||
|
|
||||||
|
try {
|
||||||
|
(data as any)[field] = this.parseItem(item, value)
|
||||||
|
} catch {
|
||||||
|
this.warn(`the field ${field} could not validate against ${value}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// console.log('result', res)
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
objectClean(data, {deep: false, cleanNull: true})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to parse the value based on the SchemaItem
|
||||||
|
*
|
||||||
|
* @throws {Error} if the value is not parseable
|
||||||
|
* @param schemaItem the schema for a specific value
|
||||||
|
* @param value the value to parse
|
||||||
|
* @returns the value parsed
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
public parseItem<T extends Item>(schemaItem: T, value: any): ImplementationItem<T> {
|
||||||
|
const isSchema = isSchemaItem(schemaItem)
|
||||||
|
if (isSchema) {
|
||||||
|
value = this.convertEmptyStringToNull(value)
|
||||||
|
value = this.parseStringifiedItem(schemaItem, value)
|
||||||
|
}
|
||||||
|
// parse an array
|
||||||
|
if (Array.isArray(schemaItem)) {
|
||||||
|
const item = schemaItem[0]
|
||||||
|
if (!item || schemaItem.length !== 1) {
|
||||||
|
throw new Error('Array does not have a child')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSchemaItem(item)) {
|
||||||
|
value = this.convertEmptyStringToNull(value)
|
||||||
|
value = this.parseStringifiedItem(item, value, true)
|
||||||
|
}
|
||||||
|
return this.parseArray(item, value) as any
|
||||||
|
// parse an object
|
||||||
|
} else if (!isSchema && isObject(schemaItem)) {
|
||||||
|
return this.parseObject(schemaItem, value) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the default value is necessary
|
||||||
|
if (isSchema && !this.isNull(schemaItem.defaultValue) && this.isNull(value)) {
|
||||||
|
value = typeof schemaItem.defaultValue === 'function' ? schemaItem.defaultValue() : schemaItem.defaultValue as any
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.validateItem(schemaItem, value)) {
|
||||||
|
return value as any
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not parse item ${schemaItem}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertEmptyStringToNull(value: any) {
|
||||||
|
if (value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStringifiedItem(item: SchemaItem, value: any, fromArray?: boolean): any {
|
||||||
|
if (typeof value !== 'string' || value === '') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (fromArray) {
|
||||||
|
const split = value.split(',')
|
||||||
|
return split.map((val) => this.parseStringifiedItem(item, val))
|
||||||
|
}
|
||||||
|
switch (item.type) {
|
||||||
|
case Number:
|
||||||
|
return Number.parseInt(value, 10)
|
||||||
|
case Boolean:
|
||||||
|
return value.toLowerCase() === 'true'
|
||||||
|
case Date:
|
||||||
|
return new Date(Date.parse(value))
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseObject<T extends Item>(schema: T, values: any): Record<string, ImplementationItem<T>> {
|
||||||
|
// validate that the values are an object
|
||||||
|
if (!isObject(values)) {
|
||||||
|
throw new Error('value is not an object')
|
||||||
|
}
|
||||||
|
|
||||||
|
// remap values based on them
|
||||||
|
return objectRemap(values, (value, key) => ({
|
||||||
|
key: key as string,
|
||||||
|
// @ts-expect-error f*ck you
|
||||||
|
value: this.parseItem(schema[key], value)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseArray<T extends Item>(schema: T, values: any): Array<ImplementationItem<T>> {
|
||||||
|
const isSchema = isSchemaItem(schema)
|
||||||
|
|
||||||
|
// set the default value if necessary
|
||||||
|
if (isSchema && !this.isNull(schema.defaultValue) && this.isNull(values)) {
|
||||||
|
values = typeof schema.defaultValue === 'function' ? schema.defaultValue() : schema.defaultValue as any
|
||||||
|
}
|
||||||
|
|
||||||
|
// if values is nullable and is null
|
||||||
|
if (isSchema && schema.nullable && this.isNull(values)) {
|
||||||
|
return values as any
|
||||||
|
}
|
||||||
|
|
||||||
|
// the values are not an array
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
throw new Error('value is not an array')
|
||||||
|
}
|
||||||
|
|
||||||
|
// map the values to their parsed values
|
||||||
|
return values.map((it) => this.parseItem(schema, it))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the value is null
|
||||||
|
*
|
||||||
|
* @param value the value to check
|
||||||
|
* @returns {boolean} if the value is null or undefined
|
||||||
|
*/
|
||||||
|
private isNull(value: any): value is undefined | null {
|
||||||
|
return typeof value === 'undefined' || value === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a specific value based on it's schema
|
||||||
|
*
|
||||||
|
* @param item the item schema
|
||||||
|
* @param value the value to validate
|
||||||
|
* @returns if the value is valid or not
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
private validateItem<T extends Item>(item: T, value: any): value is Infer<T> {
|
||||||
|
// the schema item is an array
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
this.log(item, 'is an array')
|
||||||
|
if (isSchemaItem(item[0]) && item[0].nullable && this.isNull(value)) {
|
||||||
|
this.log(value, 'is not an array but is null')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
this.log(value, 'is not an array')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (const valueValue of value) {
|
||||||
|
if (item.length !== 1) {
|
||||||
|
this.warn('It seems that your item is not properly using the correct size', item)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!this.validateItem(item[0] as Type, valueValue)) {
|
||||||
|
this.log(item[0], 'is invalid')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.log(item[0], 'is valid')
|
||||||
|
return true
|
||||||
|
} else if (typeof item === 'object') {
|
||||||
|
// object is a Record of objects OR is a SchemaItem
|
||||||
|
this.log(item, 'is an object')
|
||||||
|
if (!isSchemaItem(item)) {
|
||||||
|
this.log(item, 'is a Record of items')
|
||||||
|
for (const [valueKey, val] of Object.entries(item)) {
|
||||||
|
if (!value || typeof value !== 'object' || !(valueKey in value) || !this.validateItem(val, value[valueKey])) {
|
||||||
|
this.log(valueKey, 'is invalid')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.log(item, 'is valid')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// this.log(item, 'is a schema item')
|
||||||
|
} else {
|
||||||
|
// this.log(item, 'should be a primitive')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = this.toSchemaItem(item as Type)
|
||||||
|
|
||||||
|
this.log(value, scheme.nullable)
|
||||||
|
if (scheme.nullable && this.isNull(value)) {
|
||||||
|
this.log(value, 'is valid as a null item')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.choices && !scheme.choices.includes(value)) {
|
||||||
|
this.log(value, 'is invalid as it is not in choices', scheme.choices)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueType = typeof value
|
||||||
|
// item is a primitive
|
||||||
|
switch ((scheme.type as any).name) {
|
||||||
|
case 'Boolean':
|
||||||
|
if (valueType !== 'boolean') {
|
||||||
|
this.log(value, 'is not a boolean')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Number':
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
this.log(value, 'is not a a number')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// number specific filters
|
||||||
|
if (
|
||||||
|
scheme.max && value > scheme.max ||
|
||||||
|
scheme.min && value < scheme.min
|
||||||
|
) {
|
||||||
|
this.log(value, 'does not respect the min/max', scheme.min, scheme.max)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'String':
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
this.log(value, 'is not a a string')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (scheme.regex && !scheme.regex.test(value)) {
|
||||||
|
this.log(value, 'does not respect Regex', scheme.regex.toString())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
scheme.maxLength && value.length > scheme.maxLength ||
|
||||||
|
scheme.minLength && value.length < scheme.minLength
|
||||||
|
) {
|
||||||
|
this.log(value, 'does not respect specified min/max lengths', scheme.minLength, scheme.maxLength)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case 'Date':
|
||||||
|
if (!(value instanceof Date)) {
|
||||||
|
this.log(value, 'is not a Date')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the date is valid
|
||||||
|
if (isNaN(value.getTime())) {
|
||||||
|
this.log(value, 'is not a valid date (NaN)')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
this.warn(item, 'does not match the Schema definition, please take a look at it')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(value, 'is valid')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSchemaItem(item: Type): SchemaItem {
|
||||||
|
if (isSchemaItem(item)) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(...msg: Array<any>) {
|
||||||
|
if (this.options?.debug) {
|
||||||
|
console.log(...msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private warn(...msg: Array<any>) {
|
||||||
|
if (!this.options?.quiet) {
|
||||||
|
console.warn(...msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
src/models/State/index.ts
Normal file
22
src/models/State/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Schema, { type Impl } from 'models/Schema'
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
/**
|
||||||
|
* the project ID
|
||||||
|
*/
|
||||||
|
id: {type: String, database: {unique: true, index: true, auto: true}},
|
||||||
|
project: String, // project id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the email the project was created from
|
||||||
|
*/
|
||||||
|
name: { type: String, nullable: true },
|
||||||
|
|
||||||
|
color: { type: String, nullable: true },
|
||||||
|
|
||||||
|
preset: { type: Boolean, defaultValue: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default schema
|
||||||
|
|
||||||
|
export type ProjectObj = Impl<typeof schema>
|
13
src/pages/api/v1/projects/[id]/issues/[issueId].ts
Normal file
13
src/pages/api/v1/projects/[id]/issues/[issueId].ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
import ResponseBuilder from 'libs/ResponseBuilder'
|
||||||
|
import DaoFactory from 'models/DaoFactory'
|
||||||
|
|
||||||
|
export const GET: APIRoute = async (ctx) => {
|
||||||
|
const projectId = ctx.params.id!
|
||||||
|
const taskId = ctx.params.issueId!
|
||||||
|
const dao = DaoFactory.get('issue')
|
||||||
|
|
||||||
|
return new ResponseBuilder()
|
||||||
|
.body((await dao.findOne({project: projectId, id: taskId})))
|
||||||
|
.build()
|
||||||
|
}
|
34
src/pages/api/v1/projects/[id]/issues/index.ts
Normal file
34
src/pages/api/v1/projects/[id]/issues/index.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
import ResponseBuilder from 'libs/ResponseBuilder'
|
||||||
|
import DaoFactory from 'models/DaoFactory'
|
||||||
|
|
||||||
|
export const POST: APIRoute = async (ctx) => {
|
||||||
|
const projectId = ctx.params.id!
|
||||||
|
const dao = DaoFactory.get('issue')
|
||||||
|
const stateDao = DaoFactory.get('state')
|
||||||
|
const issueCount = await dao.findAll({
|
||||||
|
project: projectId
|
||||||
|
})
|
||||||
|
const defaultState = await stateDao.findOne({
|
||||||
|
project: projectId,
|
||||||
|
preset: true
|
||||||
|
})
|
||||||
|
console.log(issueCount)
|
||||||
|
const res = await dao.create({
|
||||||
|
...(await ctx.request.json()),
|
||||||
|
project: projectId,
|
||||||
|
localid: issueCount.rowsTotal + 1,
|
||||||
|
state: defaultState?.id ?? 'empty',
|
||||||
|
labels: []
|
||||||
|
})
|
||||||
|
return new ResponseBuilder().body(res).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async (ctx) => {
|
||||||
|
const projectId = ctx.params.id!
|
||||||
|
const dao = DaoFactory.get('issue')
|
||||||
|
|
||||||
|
return new ResponseBuilder()
|
||||||
|
.body((await dao.findAll({project: projectId})).data)
|
||||||
|
.build()
|
||||||
|
}
|
@ -1,21 +1,14 @@
|
|||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
import ResponseBuilder from 'libs/ResponseBuilder'
|
import ResponseBuilder from 'libs/ResponseBuilder'
|
||||||
|
import DaoFactory from 'models/DaoFactory'
|
||||||
|
import type { Query } from 'models/Query'
|
||||||
|
|
||||||
interface Project {
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
name: string
|
const dao = DaoFactory.get('project')
|
||||||
id: string
|
const filters: Query<any> = {}
|
||||||
}
|
url.searchParams.forEach((it, key) => {
|
||||||
|
filters[key] = it
|
||||||
export const GET: APIRoute = ({ url }) => {
|
})
|
||||||
const nameFilter = url.searchParams.get('name')
|
const res = await dao.findAll(filters)
|
||||||
return new ResponseBuilder().body(([
|
return new ResponseBuilder().body(res.data).build()
|
||||||
{
|
|
||||||
name: 'Holisané',
|
|
||||||
id: 'HOLIS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Aptatio',
|
|
||||||
id: 'APTA'
|
|
||||||
}
|
|
||||||
] as Array<Project>).filter((it) => !nameFilter || it.name.includes(nameFilter))).build()
|
|
||||||
}
|
}
|
||||||
|
@ -2,18 +2,38 @@
|
|||||||
import Button from 'components/global/Button.astro'
|
import Button from 'components/global/Button.astro'
|
||||||
import Input from 'components/global/Input.astro'
|
import Input from 'components/global/Input.astro'
|
||||||
import MainLayout from 'layouts/MainLayout.astro'
|
import MainLayout from 'layouts/MainLayout.astro'
|
||||||
|
import DaoFactory from 'models/DaoFactory'
|
||||||
|
import Schema from 'models/Schema'
|
||||||
|
import route from 'route'
|
||||||
|
|
||||||
|
const dao = DaoFactory.get('project')
|
||||||
|
|
||||||
|
if (Astro.request.method === 'POST') {
|
||||||
|
const input = new Schema({
|
||||||
|
name: String
|
||||||
|
}).parseFormData(await Astro.request.formData())
|
||||||
|
if (input) {
|
||||||
|
const project = await dao.create({
|
||||||
|
name: input.name
|
||||||
|
})
|
||||||
|
if (project) {
|
||||||
|
return Astro.redirect(route('/projects/[id]', {id: project.id, message: 'project created succefully'}))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Astro.redirect(route('/', {message: 'Error creating your project'}))
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<MainLayout title="Dzeio - Website Template">
|
<MainLayout title="Dzeio - Website Template">
|
||||||
<main class="container flex flex-col justify-center items-center h-screen gap-6">
|
<main class="container flex flex-col justify-center items-center h-screen gap-6">
|
||||||
<h1 class="text-8xl text-center">Dzeio Astro Template</h1>
|
<h1 class="text-8xl text-center">Dzeio Astro Template</h1>
|
||||||
<h2 class="text-2xl text-center">Start editing src/pages/index.astro to see your changes!</h2>
|
<h2 class="text-2xl text-center">Start editing src/pages/index.astro to see your changes!</h2>
|
||||||
<Button
|
<form method="POST">
|
||||||
data-action="/api/v1/checkout"
|
<Input label="Nom du projet" name="name" />
|
||||||
data-template="#pouet"
|
<Button>Créer</Button>
|
||||||
data-trigger="mouseenter after:100"
|
</form>
|
||||||
data-target="outerHTML"
|
|
||||||
>Checkout</Button>
|
|
||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
data-action="/api/v1/projects"
|
data-action="/api/v1/projects"
|
||||||
@ -30,7 +50,7 @@ import MainLayout from 'layouts/MainLayout.astro'
|
|||||||
</main>
|
</main>
|
||||||
<template id="projectItem">
|
<template id="projectItem">
|
||||||
<li>
|
<li>
|
||||||
<a data-attribute="name href:/project/{id}"></a>
|
<a data-attribute="name href:/projects/{id}"></a>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<!-- Define a template which contains how it is displayed -->
|
<!-- Define a template which contains how it is displayed -->
|
||||||
|
46
src/pages/projects/[id].astro
Normal file
46
src/pages/projects/[id].astro
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
import MainLayout from 'layouts/MainLayout.astro'
|
||||||
|
import Button from 'components/global/Button.astro'
|
||||||
|
import DaoFactory from 'models/DaoFactory'
|
||||||
|
import route from 'route'
|
||||||
|
import Input from 'components/global/Input.astro'
|
||||||
|
|
||||||
|
const id = Astro.params.id!
|
||||||
|
const project = await DaoFactory.get('project').findById(id)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return Astro.rewrite(route('/404'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await DaoFactory.get('issue').findAll({
|
||||||
|
project: project.id
|
||||||
|
})
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainLayout>
|
||||||
|
<main class="container flex flex-col gap-24 justify-center items-center md:mt-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-6xl text-center font-bold">{project.name}</h1>
|
||||||
|
<ul
|
||||||
|
data-action={route('/api/v1/projects/[id]/issues', {id: project.id})}
|
||||||
|
data-multiple
|
||||||
|
data-template="template#issue"
|
||||||
|
>{tasks.data.map((it) => (<li>{project.displayID}-{it.localid} {it.name}</li>))}</ul>
|
||||||
|
<template id="issue"><li data-template="#issueTemplate" data-target="innerHTML #issue-details" data-attribute={`'${project.displayID}-{localid} {name}' data-action:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}`}></li></template>
|
||||||
|
<form data-action={route('/api/v1/projects/[id]/issues', {id: project.id})} data-method="POST" data-run="ul[data-action]">
|
||||||
|
<Input name="name" label="Ajouter une Issue" placeholder="nom de la tache" />
|
||||||
|
<Button>Ajouter</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div id="issue-details"></div>
|
||||||
|
<template id="issueTemplate">
|
||||||
|
<h2 data-attribute="name"></h2>
|
||||||
|
<ul data-loop="labels">
|
||||||
|
<li data-attribute="this"></li>
|
||||||
|
</ul>
|
||||||
|
<p data-attribute="description"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</MainLayout>
|
70
src/pages/test.astro
Normal file
70
src/pages/test.astro
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
import Button from 'components/global/Button.astro'
|
||||||
|
import Input from 'components/global/Input.astro'
|
||||||
|
import MainLayout from 'layouts/MainLayout.astro'
|
||||||
|
import DaoFactory from 'models/DaoFactory'
|
||||||
|
|
||||||
|
const dao = DaoFactory.get('project')
|
||||||
|
const res = await dao.create({
|
||||||
|
name: 'caca'
|
||||||
|
})
|
||||||
|
console.log(res)
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainLayout title="Dzeio - Website Template">
|
||||||
|
<main class="container flex flex-col justify-center items-center h-screen gap-6">
|
||||||
|
<h1 class="text-8xl text-center">Dzeio Astro Template</h1>
|
||||||
|
<h2 class="text-2xl text-center">Start editing src/pages/index.astro to see your changes!</h2>
|
||||||
|
<Button
|
||||||
|
data-action="/api/v1/checkout"
|
||||||
|
data-template="#pouet"
|
||||||
|
data-trigger="mouseenter after:100"
|
||||||
|
data-target="outerHTML"
|
||||||
|
>Checkout</Button>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
data-action="/api/v1/projects"
|
||||||
|
data-template="#projectItem"
|
||||||
|
data-trigger="keydown load after:100"
|
||||||
|
data-target="innerHTML ul"
|
||||||
|
data-multiple
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p>Results</p>
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
<template id="projectItem">
|
||||||
|
<li>
|
||||||
|
<a data-attribute="name href:/project/{id}"></a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<!-- Define a template which contains how it is displayed -->
|
||||||
|
<template id="pouet">
|
||||||
|
<div>
|
||||||
|
<!-- Define the attribute in the object that will be assigned -->
|
||||||
|
<p data-attribute="caca"></p>
|
||||||
|
<!-- You can change the display type by selecting `html` | `innerHTML` | `outerHTML` | `text` | `innerText` | `outerText` -->
|
||||||
|
<p data-attribute="caca" data-type="html"></p>
|
||||||
|
<!-- You can even add another Requester inside the template! -->
|
||||||
|
<p
|
||||||
|
data-attribute="caca value:caca"
|
||||||
|
|
||||||
|
data-action="/api/v1/checkout"
|
||||||
|
data-template="#pouet"
|
||||||
|
data-trigger="mouseover"
|
||||||
|
data-target="outerHTML"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template id="list">
|
||||||
|
<li data-attribute="this"></li>
|
||||||
|
</template>
|
||||||
|
</MainLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Hyperion from 'libs/Hyperion'
|
||||||
|
|
||||||
|
Hyperion.setup()
|
||||||
|
</script>
|
Reference in New Issue
Block a user