feat: Add base to project

Signed-off-by: Avior <git@avior.me>
This commit is contained in:
2024-05-16 16:45:50 +02:00
parent 9b2d412a9e
commit f50ec828fb
36 changed files with 5248 additions and 1698 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
POSTGRES_HOST=
POSTGRES_PORT=5432

View File

@ -68,5 +68,8 @@ export default defineConfig({
}
},
experimental: {
rewriting: true,
},
})

View File

@ -14,7 +14,7 @@ import { objectLoop } from '@dzeio/object-util'
*
* @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
// 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
objectLoop(params, (value, key) => {
const search = \`[\${key}]\`
if (!skipEncoding) {
value = encodeURI(value.toString())
key = encodeURI(key)
} else {
value = value.toString()
}
if (!result.includes(search)) {
externalQueries += \`\${encodeURI(key)}=\${value}&\`
externalQueries += \`\${key}=\${value}&\`
} else {
result = result.replace(search, value)
}
@ -53,8 +58,8 @@ async function updateRoutes(output: string, routes: Array<string>) {
let file = baseFile
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\treturn formatRoute(route, query)'
file += '\n\nexport default function route(route: Routes, query?: Record<string, string | number>, skipEncoding = false) {'
file += '\n\treturn formatRoute(route, query, skipEncoding)'
file += '\n}\n'
await fs.writeFile(output, file)

3639
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@
"@dzeio/url-manager": "^1",
"astro": "^4",
"lucide-astro": "^0",
"pg": "^8.11.5",
"sharp": "^0",
"simple-icons-astro": "^10",
"tailwindcss": "^3"
@ -30,6 +31,7 @@
"@astrojs/check": "^0",
"@playwright/test": "^1",
"@types/node": "^20",
"@types/pg": "^8.11.6",
"@vitest/coverage-v8": "^1",
"typescript": "^5",
"vitest": "^1"

5
src/env.d.ts vendored
View File

@ -5,6 +5,11 @@
* Environment variables declaration
*/
interface ImportMetaEnv {
POSTGRES_USER: string
POSTGRES_PASSWORD: string
POSTGRES_DATABASE: string
POSTGRES_HOST: string
POSTGRES_PORT: string
}
interface ImportMeta {

View File

@ -16,3 +16,9 @@ export interface Props extends HeadProps {
<slot />
</body>
</html>
<script>
import Hyperion from 'libs/Hyperion'
Hyperion.setup()
</script>

3
src/libs/AsyncUtils.ts Normal file
View File

@ -0,0 +1,3 @@
export function wait(timeMs: number) {
return new Promise((res) => setTimeout(res, timeMs))
}

49
src/libs/Env.ts Normal file
View 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])
}

View File

@ -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
@ -63,7 +63,7 @@ export default class Hyperion {
*/
private initItem(it: HTMLElement) {
// get the trigger action
let trigger = it.dataset.trigger ?? 'click'
let trigger = it.dataset.trigger ?? (it.tagName === 'FORM' ? 'submit' : 'click')
// the triggering options
const options: {
@ -73,10 +73,9 @@ export default class Hyperion {
} = {}
// handle options
if (trigger.includes(' ')) {
const splitted = trigger.split(' ')
trigger = splitted.shift()!
for (const item of splitted) {
console.log('ah', splitted, item)
// item runs only once
if (item === 'once') {
options.once = true
@ -92,14 +91,20 @@ export default class Hyperion {
if (key === 'after') {
options.after = parseInt(value!)
}
continue
}
}
// trigger is not special
trigger = item
}
let timeout: NodeJS.Timeout | undefined
// the triggering function
let fn = () => {
let fn = (ev?: Event) => {
if (ev) {
ev.preventDefault()
}
if (options.after) { // handle options:after
if (timeout) {
clearTimeout(timeout)
@ -136,11 +141,13 @@ export default class Hyperion {
* @param it the element to process
*/
private async processElement(it: HTMLElement) {
console.log(it)
const action = it.dataset.action // get the URL to try
const method = it.dataset.method ?? 'GET' // get the method to use
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
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 params: Record<string, any> = {} // the request parameters
@ -149,28 +156,17 @@ export default class Hyperion {
*/
// handle mendatory elements that are not necessary set
if (!templateQuery || !action) {
if ((!templateQuery && !runQuery) || !action) {
it.dispatchEvent(new CustomEvent('hyperion:attributeError', {
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)
// 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
let targetEl: HTMLElement | null = it
@ -195,7 +191,7 @@ export default class Hyperion {
// handle if the target was not found
if (!targetEl) {
template.dispatchEvent(new CustomEvent('hyperion:targetError', {
it.dispatchEvent(new CustomEvent('hyperion:targetError', {
detail: {
type: 'target not found',
data: target
@ -253,6 +249,32 @@ export default class Hyperion {
// transform the response into 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) {
for (const child of subPath.split('.')) {
@ -315,18 +337,55 @@ export default class Hyperion {
// clone the template
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
clone.querySelectorAll<HTMLElement>('[data-attribute]').forEach((it) => {
// get the raw attribute
const attrRaw = it.dataset.attribute!
// parse into an array
let attrs: Array<string>
if (attrRaw.includes(' ')) {
attrs = attrRaw.split(' ')
} else {
attrs = [attrRaw]
let attrs: Array<string> = []
let quoteCount = 0
let current = ''
const splitted = attrRaw.split('')
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
for (let attr of attrs) {
@ -352,7 +411,7 @@ export default class Hyperion {
}
content = attr
} else { // handle basic string
content = attr === 'this' ? data : data[attr]
content = attr === 'this' ? data : objectGet(data, attr)
}
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
it.removeAttribute('data-attribute')
it.removeAttribute('data-type')
// it.removeAttribute('data-attribute')
// it.removeAttribute('data-type')
})
// setup the clone to work if it contains Hyperion markup
@ -390,3 +449,36 @@ export default class Hyperion {
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]
}
}

View 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
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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')
}
}

View 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
}
}

View 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
}
}

View File

@ -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
*
* 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
@ -11,7 +23,12 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
* @param obj the object to create
* @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
@ -19,7 +36,9 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
* @param obj the object to create
* @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
@ -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
* @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
@ -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
* @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)
/**
* 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>)
public async find(query: Parameters<this['findAll']>[0], ...args: Array<any>) {
return this.findAll(query, ...args)
}
/**
@ -57,7 +72,19 @@ export default abstract class Dao<Object extends { id: any } = { id: any }> {
* @param id the id of the object
* @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)
}
@ -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
* @returns the first element matching with the query or null otherwise
*/
public async findOne(query?: Partial<Object>): Promise<Object | null> {
return (await this.findAll(query))[0] ?? null
public async findOne(query?: Parameters<this['findAll']>[0], ...args: Array<any>): Promise<Implementation<S['model']> | 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
* @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
* @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> {
if (!this.adapter.patch) {
const query = await this.findById(id)
if (!query) {
return null
}
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
* @param obj the object to update/insert
* @returns the object is updated/inserted or null otherwise
*/
public async upsert(object: Object | Omit<Object, 'id' | 'created' | 'updated'>): Promise<Object | null> {
if ('id' in object) {
return this.update(object)
public async upsert(object: Partial<Implementation<S['model']>>): Promise<Partial<Implementation<S['model']>> | null> {
if (!this.adapter.upsert) {
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
* @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)
*/
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
View 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>
}

View File

@ -1,8 +1,8 @@
/**
* TODO:
* Add to `DaoItem` your model name
* Add to the function `initDao` the Dao
*/
import PostgresAdapter from './Adapters/PostgresAdapter'
import Dao from './Dao'
import Issue from './Issue'
import Project from './Project'
import State from './State'
/**
* the different Daos that can be initialized
@ -10,11 +10,15 @@
* Touch this interface to define which key is linked to which Dao
*/
interface DaoItem {
project: Dao<typeof Project>
issue: Dao<typeof Issue>
state: Dao<typeof State>
}
/**
* Class to get any DAO
*/
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export default class DaoFactory {
/**
* reference of the different Daos for a correct singleton implementation
@ -46,8 +50,12 @@ export default class DaoFactory {
* @param item the element to init
* @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 {
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
}
}

49
src/models/Issue/index.ts Normal file
View 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>

View 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

View 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

View 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

View 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())
}
}

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

View 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()
}

View 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()
}

View File

@ -1,21 +1,14 @@
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory'
import type { Query } from 'models/Query'
interface Project {
name: string
id: string
}
export const GET: APIRoute = ({ url }) => {
const nameFilter = url.searchParams.get('name')
return new ResponseBuilder().body(([
{
name: 'Holisané',
id: 'HOLIS'
},
{
name: 'Aptatio',
id: 'APTA'
}
] as Array<Project>).filter((it) => !nameFilter || it.name.includes(nameFilter))).build()
export const GET: APIRoute = async ({ url }) => {
const dao = DaoFactory.get('project')
const filters: Query<any> = {}
url.searchParams.forEach((it, key) => {
filters[key] = it
})
const res = await dao.findAll(filters)
return new ResponseBuilder().body(res.data).build()
}

View File

@ -2,18 +2,38 @@
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'
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">
<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>
<form method="POST">
<Input label="Nom du projet" name="name" />
<Button>Créer</Button>
</form>
<Input
name="name"
data-action="/api/v1/projects"
@ -30,7 +50,7 @@ import MainLayout from 'layouts/MainLayout.astro'
</main>
<template id="projectItem">
<li>
<a data-attribute="name href:/project/{id}"></a>
<a data-attribute="name href:/projects/{id}"></a>
</li>
</template>
<!-- Define a template which contains how it is displayed -->

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