generated from avior/template-web-astro
9
package-lock.json
generated
9
package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@dzeio/object-util": "^1",
|
||||
"@dzeio/url-manager": "^1",
|
||||
"astro": "^4",
|
||||
"hyperions": "^1.0.0-beta.8",
|
||||
"lucide-astro": "^0",
|
||||
"pg": "^8.11.5",
|
||||
"sharp": "^0",
|
||||
@ -3739,6 +3740,14 @@
|
||||
"node": ">=16.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hyperions": {
|
||||
"version": "1.0.0-beta.10",
|
||||
"resolved": "https://registry.npmjs.org/hyperions/-/hyperions-1.0.0-beta.10.tgz",
|
||||
"integrity": "sha512-Jyx9WPQOsUfMhhzQEibsR1sCLlf2QajZjDDkhwcsIcs2eQ2P3OOoMJtk0xZR6547e1sTn+fjN666ABGN8SLMYQ==",
|
||||
"dependencies": {
|
||||
"@dzeio/object-util": "^1.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"@dzeio/object-util": "^1",
|
||||
"@dzeio/url-manager": "^1",
|
||||
"astro": "^4",
|
||||
"hyperions": "^1.0.0-beta.8",
|
||||
"lucide-astro": "^0",
|
||||
"pg": "^8.11.5",
|
||||
"sharp": "^0",
|
||||
|
23
src/Schema/Items/DzeioLiteral.ts
Normal file
23
src/Schema/Items/DzeioLiteral.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import SchemaItem, { type JSONSchemaItem } from '../SchemaItem'
|
||||
|
||||
export default class DzeioLiteral<T> extends SchemaItem<T> {
|
||||
public constructor(private readonly value: T) {
|
||||
super()
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input === value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is T {
|
||||
return typeof input === typeof this.value
|
||||
}
|
||||
|
||||
public override toJSON(): JSONSchemaItem {
|
||||
return {
|
||||
type: 'literal',
|
||||
params: [this.value as string]
|
||||
}
|
||||
}
|
||||
}
|
93
src/Schema/Items/SchemaArray.ts
Normal file
93
src/Schema/Items/SchemaArray.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { ValidationError, ValidationResult } from '..'
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaArray<A> extends SchemaItem<Array<A>> {
|
||||
|
||||
public constructor(
|
||||
private readonly values: SchemaItem<A>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public override parse(input: unknown): A[] | unknown {
|
||||
// let master handle the first pass is desired
|
||||
input = super.parse(input)
|
||||
|
||||
if (!Array.isArray(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
const clone = []
|
||||
for (const item of input) {
|
||||
clone.push(this.values.parse(item))
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
public override validate(input: A[], fast = false): ValidationResult<A[]> {
|
||||
const tmp = super.validate(input, fast)
|
||||
if (tmp.error) {
|
||||
return tmp
|
||||
}
|
||||
const clone: Array<A> = []
|
||||
const errs: Array<ValidationError> = []
|
||||
for (let idx = 0; idx < tmp.object.length; idx++) {
|
||||
const item = tmp.object[idx];
|
||||
const res = this.values.validate(item as A)
|
||||
if (res.error) {
|
||||
const errors = res.error.map((it) => ({
|
||||
message: it.message,
|
||||
field: it.field ? `${idx}.${it.field}` : idx.toString()
|
||||
}))
|
||||
if (fast) {
|
||||
return {
|
||||
error: errors
|
||||
}
|
||||
}
|
||||
errs.push(...errors)
|
||||
} else {
|
||||
clone.push(res.object as A)
|
||||
}
|
||||
}
|
||||
|
||||
if (errs.length > 0) {
|
||||
return {
|
||||
error: errs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
object: clone
|
||||
}
|
||||
}
|
||||
|
||||
public override transform(input: A[]): A[] {
|
||||
const clone = []
|
||||
for (const item of super.transform(input)) {
|
||||
clone.push(this.values.transform(item))
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
/**
|
||||
* transform the array so it only contains one of each elements
|
||||
*/
|
||||
public unique(): this {
|
||||
this.transforms.push((input) => input.filter((it, idx) => input.indexOf(it) === idx))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is Array<A> {
|
||||
return Array.isArray(input)
|
||||
}
|
||||
|
||||
// public override toJSON(): JSONSchemaItem {
|
||||
// return {
|
||||
// type: 'array',
|
||||
// childs: this.values
|
||||
// }
|
||||
// }
|
||||
}
|
8
src/Schema/Items/SchemaBoolean.ts
Normal file
8
src/Schema/Items/SchemaBoolean.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaBoolean extends SchemaItem<boolean> {
|
||||
|
||||
public override isOfType(input: unknown): input is boolean {
|
||||
return typeof input === 'boolean'
|
||||
}
|
||||
}
|
62
src/Schema/Items/SchemaDate.ts
Normal file
62
src/Schema/Items/SchemaDate.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaDate extends SchemaItem<Date> {
|
||||
|
||||
public parseString(): this {
|
||||
this.parseActions.push((input) => typeof input === 'string' ? new Date(input) : input)
|
||||
return this
|
||||
}
|
||||
|
||||
public parseFrenchDate(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (typeof input !== 'string') {
|
||||
return input
|
||||
}
|
||||
const splitted = input.split('/').map((it) => Number.parseInt(it, 10))
|
||||
if (splitted.length !== 3) {
|
||||
return input
|
||||
}
|
||||
return new Date(Date.UTC(splitted[2]!, splitted[1], splitted[0]))
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public min(value: Date, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input >= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public parseFromExcelString(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (typeof input !== 'string') {
|
||||
return input
|
||||
}
|
||||
const days = parseFloat(input)
|
||||
const millis = days * 24 * 60 * 60 * 1000
|
||||
const date = new Date('1900-01-01')
|
||||
date.setTime(date.getTime() + millis)
|
||||
return date
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public max(value: Date, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input <= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is Date {
|
||||
return input instanceof Date && !isNaN(input.getTime())
|
||||
}
|
||||
}
|
21
src/Schema/Items/SchemaFile.ts
Normal file
21
src/Schema/Items/SchemaFile.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaFile extends SchemaItem<File> {
|
||||
constructor () {
|
||||
super()
|
||||
this.parseActions.push((input) => this.isOfType(input) && input.size > 0 ? input : undefined)
|
||||
}
|
||||
|
||||
public extension(ext: string, message?: string): this {
|
||||
this.validations.push({
|
||||
fn: (input) => input.name.endsWith(ext),
|
||||
message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is File {
|
||||
return input instanceof File
|
||||
}
|
||||
}
|
65
src/Schema/Items/SchemaNullable.ts
Normal file
65
src/Schema/Items/SchemaNullable.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { ValidationResult } from '..'
|
||||
import SchemaItem from '../SchemaItem'
|
||||
import { isNull } from '../utils'
|
||||
|
||||
export default class SchemaNullable<A> extends SchemaItem<A | undefined | null> {
|
||||
|
||||
public constructor(private readonly item: SchemaItem<A>) {
|
||||
super()
|
||||
}
|
||||
|
||||
public emptyAsNull(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (typeof input === 'string' && input === '') {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public falthyAsNull(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override transform(input: A | null | undefined): A | null | undefined {
|
||||
const transformed = super.transform(input)
|
||||
|
||||
if (isNull(transformed) || isNull(input)) {
|
||||
return transformed
|
||||
}
|
||||
|
||||
return this.item.transform(input)
|
||||
}
|
||||
|
||||
public override validate(input: A | null | undefined): ValidationResult<A | null | undefined> {
|
||||
if (isNull(input)) {
|
||||
return {
|
||||
object: input
|
||||
}
|
||||
}
|
||||
return this.item.validate(input)
|
||||
}
|
||||
|
||||
public override parse(input: unknown): (A | null | undefined) | unknown {
|
||||
const parsed = super.parse(input)
|
||||
|
||||
if (isNull(parsed) || isNull(input)) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return this.item.parse(input)
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is A | undefined | null {
|
||||
return isNull(input) || this.item.isOfType(input)
|
||||
}
|
||||
}
|
89
src/Schema/Items/SchemaNumber.ts
Normal file
89
src/Schema/Items/SchemaNumber.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaNumber extends SchemaItem<number> {
|
||||
|
||||
public min(...params: Parameters<SchemaNumber['gte']>): this {
|
||||
return this.gte(...params)
|
||||
}
|
||||
|
||||
public max(...params: Parameters<SchemaNumber['lte']>): this {
|
||||
return this.lte(...params)
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is less or equal than {@link value}
|
||||
* @param value the maxumum value (inclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public lte(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input <= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is more or equal than {@link value}
|
||||
* @param value the minimum value (inclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public gte(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input >= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is less than {@link value}
|
||||
* @param value the maxumum value (exclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public lt(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input < value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is more than {@link value}
|
||||
* @param value the minimum value (exclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public gt(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input > value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse strings before validating
|
||||
*/
|
||||
public parseString(): this {
|
||||
this.parseActions.push((input) =>
|
||||
typeof input === 'string' ? Number.parseFloat(input) : input
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is number {
|
||||
return typeof input === 'number' && !Number.isNaN(input)
|
||||
}
|
||||
}
|
88
src/Schema/Items/SchemaRecord.ts
Normal file
88
src/Schema/Items/SchemaRecord.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { isObject, objectLoop, objectRemap } from '@dzeio/object-util'
|
||||
import type { ValidationError, ValidationResult } from '..'
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaRecord<A extends string | number | symbol, B> extends SchemaItem<Record<A, B>> {
|
||||
|
||||
public constructor(
|
||||
private readonly key: SchemaItem<A>,
|
||||
private readonly values: SchemaItem<B>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public override parse(input: unknown): unknown {
|
||||
input = super.parse(input)
|
||||
|
||||
if (!this.isOfType(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
const finalObj: Record<A, B> = {} as Record<A, B>
|
||||
const error = objectLoop(input, (value, key) => {
|
||||
const res1 = this.key.parse(key)
|
||||
const res2 = this.values.parse(value)
|
||||
if (typeof res1 !== 'string' && typeof res1 !== 'number') {
|
||||
return false
|
||||
}
|
||||
// @ts-expect-error normal behavior
|
||||
finalObj[res1] = res2
|
||||
return true
|
||||
})
|
||||
if (error) {
|
||||
return input
|
||||
}
|
||||
return finalObj
|
||||
}
|
||||
|
||||
public override transform(input: Record<A, B>): Record<A, B> {
|
||||
return objectRemap(super.transform(input), (value, key) => {
|
||||
return {
|
||||
key: this.key.transform(key),
|
||||
value: this.values.transform(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public override validate(input: Record<A, B>, fast = false): ValidationResult<Record<A, B>> {
|
||||
const tmp = super.validate(input)
|
||||
if (tmp.error) {
|
||||
return tmp
|
||||
}
|
||||
|
||||
const errs: Array<ValidationError> = []
|
||||
const finalObj: Record<A, B> = {} as Record<A, B>
|
||||
|
||||
objectLoop(tmp.object, (value, key) => {
|
||||
const res1 = this.key.validate(key)
|
||||
const res2 = this.values.validate(value)
|
||||
const localErrs = (res1.error ?? []).concat(...(res2.error ?? []))
|
||||
if (localErrs.length > 0) {
|
||||
errs.push(...localErrs.map((it) => ({
|
||||
message: it.message,
|
||||
field: it.field ? `${key as string}.${it.field}` : key.toString()
|
||||
})))
|
||||
return !fast
|
||||
} else {
|
||||
// @ts-expect-error the check in the if assure the typing below
|
||||
finalObj[res1.object] = res2.object
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (errs.length > 0) {
|
||||
return {
|
||||
error: errs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
object: finalObj
|
||||
}
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is Record<A, B> {
|
||||
return isObject(input) && Object.prototype.toString.call(input) === '[object Object]'
|
||||
}
|
||||
}
|
76
src/Schema/Items/SchemaString.ts
Normal file
76
src/Schema/Items/SchemaString.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
import SchemaNullable from './SchemaNullable'
|
||||
|
||||
export default class SchemaString extends SchemaItem<string> {
|
||||
/**
|
||||
* force the input text to be a minimum of `value` size
|
||||
* @param value the minimum length of the text
|
||||
* @param message the message to display on an error
|
||||
*/
|
||||
public min(value: number, message?: string): SchemaString {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input.length >= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* force the input text to be a maximum of `value` size
|
||||
* @param value the maximum length of the text
|
||||
* @param message the message to display on an error
|
||||
*/
|
||||
public max(value: number, message?: string): SchemaString {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input.length <= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* the value must not be empty (`''`)
|
||||
* @param message
|
||||
* @returns
|
||||
*/
|
||||
public notEmpty(message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input !== ''
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* note: this nullable MUST be used last as it change the type of the returned function
|
||||
*/
|
||||
public nullable() {
|
||||
return new SchemaNullable(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* force the input text to respect a Regexp
|
||||
* @param regex the regex to validate against
|
||||
* @param message the message to display on an error
|
||||
*/
|
||||
public regex(regex: RegExp, message?: string): SchemaString {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return regex.test(input)
|
||||
},
|
||||
message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is string {
|
||||
return typeof input === 'string'
|
||||
}
|
||||
}
|
25
src/Schema/README.md
Normal file
25
src/Schema/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
a Full featured and lightweight Schema validation/parsing library
|
||||
|
||||
it is meant to be used for input validation
|
||||
|
||||
example :
|
||||
|
||||
```ts
|
||||
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||
|
||||
const schema = new Schema({
|
||||
test: s.record(s.string(), s.object({
|
||||
a: s.number().parseString().min(3, 'a is too small')
|
||||
}))
|
||||
})
|
||||
|
||||
const t = {
|
||||
test: {
|
||||
b: {a: '34'}
|
||||
}
|
||||
}
|
||||
|
||||
// validate that `t` is coherant with the schema above
|
||||
const { object, error } = schema.validate(t)
|
||||
console.log(object, error)
|
||||
```
|
169
src/Schema/SchemaItem.ts
Normal file
169
src/Schema/SchemaItem.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import type { ValidationResult } from '.'
|
||||
import Schema from '.'
|
||||
import { isNull } from './utils'
|
||||
|
||||
export interface Messages {
|
||||
globalInvalid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* An element of a schema
|
||||
*/
|
||||
export default abstract class SchemaItem<T> {
|
||||
/**
|
||||
* get additionnal attributes used to make the Schema work with outside libs
|
||||
*/
|
||||
public attributes: Array<string> = []
|
||||
|
||||
/**
|
||||
* the list of validations
|
||||
*/
|
||||
protected validations: Array<{
|
||||
fn: (input: T) => boolean
|
||||
message?: string | undefined
|
||||
}> = []
|
||||
|
||||
protected parseActions: Array<(input: unknown) => T | unknown> = []
|
||||
protected transforms: Array<(input: T) => T> = []
|
||||
|
||||
/**
|
||||
* set the list of attributes for the item of the schema
|
||||
* @param attributes the attributes
|
||||
*/
|
||||
public attr(...attributes: Array<string>) {
|
||||
this.attributes = attributes
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* set the default value of the schema element
|
||||
* @param value the default value
|
||||
* @param strict if strict, it will use it for null/undefined, else it will check for falthy values
|
||||
*/
|
||||
public defaultValue(value: T, strict = true) {
|
||||
this.parseActions.push((input) => {
|
||||
if (strict && isNull(input)) {
|
||||
return value
|
||||
}
|
||||
if (!value) {
|
||||
return input
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param values the possible values the field can be
|
||||
* @param message the message returned if it does not respect the value
|
||||
*/
|
||||
public in(values: Array<T>, message?: string) {
|
||||
this.validations.push({
|
||||
fn: (input) => values.includes(input),
|
||||
message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse the input from another format
|
||||
*
|
||||
* @param input the input to transform, it is done before validation, so the value can be anything
|
||||
* @returns the transformed value
|
||||
*/
|
||||
public parse(input: unknown): T | unknown {
|
||||
for (const transform of this.parseActions) {
|
||||
const tmp = transform(input)
|
||||
if (this.isOfType(tmp)) {
|
||||
return tmp
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* transform a valid value
|
||||
*
|
||||
* @param input the input to transform, it MUST be validated beforehand
|
||||
* @returns the transformed value
|
||||
*/
|
||||
public transform(input: T): T {
|
||||
let res = input
|
||||
|
||||
for (const action of this.transforms) {
|
||||
res = action(res)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the input is valid or not
|
||||
* @param input the input to validate
|
||||
* @param fast if true the validation stops at the first error
|
||||
* @returns a string if it's not valid, else null
|
||||
*/
|
||||
public validate(input: T, fast = false): ValidationResult<T> {
|
||||
for (const validation of this.validations) {
|
||||
if (!validation.fn(input as T)) {
|
||||
return {
|
||||
error: [{
|
||||
message: validation.message ?? Schema.messages.globalInvalid
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
object: input as T
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the input value is of the type of the schema item
|
||||
*
|
||||
* it makes others functions easier to works with
|
||||
* @param input the input to validate
|
||||
*/
|
||||
public abstract isOfType(input: unknown): input is T
|
||||
|
||||
// public abstract toJSON(): JSONSchemaItem
|
||||
}
|
||||
|
||||
type Parseable = string | number | boolean
|
||||
|
||||
export interface ValidatorJSON {
|
||||
/**
|
||||
* the function name (ex: `min`, `max`)
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* the function parameters
|
||||
*/
|
||||
params?: Array<Parseable>
|
||||
}
|
||||
|
||||
export interface JSONSchemaItem {
|
||||
/**
|
||||
* Schema item
|
||||
*
|
||||
* ex: `string`, `number`, `boolean`, ...
|
||||
*/
|
||||
type: string
|
||||
/**
|
||||
* constructor params
|
||||
*/
|
||||
params?: Array<Parseable>
|
||||
/**
|
||||
* list of attributes
|
||||
*/
|
||||
attributes?: Array<string>
|
||||
actions?: Array<ValidatorJSON>
|
||||
}
|
||||
|
||||
export type JSONSchema = {
|
||||
[a: string]: JSONSchemaItem
|
||||
}
|
216
src/Schema/index.ts
Normal file
216
src/Schema/index.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { isObject, objectLoop } from '@dzeio/object-util'
|
||||
import DzeioLiteral from './Items/DzeioLiteral'
|
||||
import SchemaArray from './Items/SchemaArray'
|
||||
import SchemaBoolean from './Items/SchemaBoolean'
|
||||
import SchemaDate from './Items/SchemaDate'
|
||||
import SchemaFile from './Items/SchemaFile'
|
||||
import SchemaNullable from './Items/SchemaNullable'
|
||||
import SchemaNumber from './Items/SchemaNumber'
|
||||
import SchemaRecord from './Items/SchemaRecord'
|
||||
import SchemaString from './Items/SchemaString'
|
||||
import SchemaItem from './SchemaItem'
|
||||
|
||||
export interface ValidationError {
|
||||
message: string
|
||||
field?: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type ValidationResult<T> = {
|
||||
object: T
|
||||
error?: undefined
|
||||
} | {
|
||||
object?: undefined
|
||||
error: Array<ValidationError>
|
||||
}
|
||||
|
||||
export type Model = Record<string, SchemaItem<any>>
|
||||
|
||||
export type SchemaInfer<S extends Schema> = ModelInfer<S['model']>
|
||||
|
||||
export type ModelInfer<M extends Model> = {
|
||||
[key in keyof M]: ReturnType<M[key]['transform']>
|
||||
}
|
||||
|
||||
/**
|
||||
* A schema to validate input or external datas
|
||||
*/
|
||||
export default class Schema<M extends Model = Model> extends SchemaItem<ModelInfer<Model>> {
|
||||
|
||||
public static messages = {
|
||||
typeInvalid: 'Type of field is not valid',
|
||||
notAnObject: 'the data submitted is not valid',
|
||||
globalInvalid: 'the field is invalid'
|
||||
}
|
||||
|
||||
public constructor(public readonly model: M) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* See {@link SchemaString}
|
||||
*/
|
||||
public static string(
|
||||
...inputs: ConstructorParameters<typeof SchemaString>
|
||||
): SchemaString {
|
||||
return new SchemaString(...inputs)
|
||||
}
|
||||
|
||||
public static file(
|
||||
...inputs: ConstructorParameters<typeof SchemaFile>
|
||||
): SchemaFile {
|
||||
return new SchemaFile(...inputs)
|
||||
}
|
||||
|
||||
public static number(
|
||||
...inputs: ConstructorParameters<typeof SchemaNumber>
|
||||
): SchemaNumber {
|
||||
return new SchemaNumber(...inputs)
|
||||
}
|
||||
|
||||
public static date(
|
||||
...inputs: ConstructorParameters<typeof SchemaDate>
|
||||
): SchemaDate {
|
||||
return new SchemaDate(...inputs)
|
||||
}
|
||||
|
||||
public static literal<T>(
|
||||
...inputs: ConstructorParameters<typeof DzeioLiteral<T>>
|
||||
): DzeioLiteral<T> {
|
||||
return new DzeioLiteral<T>(...inputs)
|
||||
}
|
||||
|
||||
public static object<T extends Model>(
|
||||
...inputs: ConstructorParameters<typeof Schema<T>>
|
||||
): Schema<T> {
|
||||
return new Schema(...inputs)
|
||||
}
|
||||
|
||||
public static record<A extends string | number, B>(
|
||||
...inputs: ConstructorParameters<typeof SchemaRecord<A, B>>
|
||||
): SchemaRecord<A, B> {
|
||||
return new SchemaRecord<A, B>(...inputs)
|
||||
}
|
||||
|
||||
public static array<A>(
|
||||
...inputs: ConstructorParameters<typeof SchemaArray<A>>
|
||||
): SchemaArray<A> {
|
||||
return new SchemaArray<A>(...inputs)
|
||||
}
|
||||
|
||||
public static nullable<A>(
|
||||
...inputs: ConstructorParameters<typeof SchemaNullable<A>>
|
||||
): SchemaNullable<A> {
|
||||
return new SchemaNullable<A>(...inputs)
|
||||
}
|
||||
|
||||
public static boolean(
|
||||
...inputs: ConstructorParameters<typeof SchemaBoolean>
|
||||
): SchemaBoolean {
|
||||
return new SchemaBoolean(...inputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param query the URL params to validate
|
||||
* @returns
|
||||
*/
|
||||
public validateQuery(query: URLSearchParams, fast = false): ReturnType<Schema<M>['validate']> {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (const [key, value] of query) {
|
||||
record[key] = value
|
||||
}
|
||||
|
||||
return this.validate(record, fast)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param form the form to validate
|
||||
*/
|
||||
public validateForm(form: HTMLFormElement, fast = false): ReturnType<Schema<M>['validate']> {
|
||||
const data = new FormData(form)
|
||||
return this.validateFormData(data, fast)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param data the FormData to validate
|
||||
* @returns
|
||||
*/
|
||||
public validateFormData(data: FormData, fast = false): ReturnType<Schema<M>['validate']> {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (const [key, value] of data) {
|
||||
const isArray = this.model[key]?.isOfType([]) ?? false
|
||||
record[key] = isArray ? data.getAll(key) : value
|
||||
}
|
||||
|
||||
return this.validate(record, fast)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param input the data to validate
|
||||
* @param options additionnal validation options
|
||||
* @returns blablabla
|
||||
*/
|
||||
public override validate(input: unknown, fast = false): ValidationResult<SchemaInfer<this>> {
|
||||
if (!isObject(input)) {
|
||||
return {
|
||||
error: [{
|
||||
message: Schema.messages.notAnObject
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const errors: Array<ValidationError> = []
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
const res: ModelInfer<M> = {} as any
|
||||
objectLoop(this.model, (v, k) => {
|
||||
// parse value from other formats
|
||||
const value = v.parse(input[k])
|
||||
|
||||
// validate that the value is of type
|
||||
if (!v.isOfType(value)) {
|
||||
errors.push({
|
||||
message: Schema.messages.typeInvalid,
|
||||
field: k,
|
||||
value: value
|
||||
})
|
||||
return !fast
|
||||
}
|
||||
|
||||
// run validations
|
||||
const invalid = v.validate(value)
|
||||
if (invalid.error) {
|
||||
errors.push(...invalid.error.map((it) => ({
|
||||
message: it.message,
|
||||
field: it.field ? `${k}.${it.field}` : k
|
||||
})))
|
||||
return !fast
|
||||
}
|
||||
|
||||
// transform and assign final value
|
||||
// @ts-expect-error normal behavior
|
||||
res[k] = v.transform(value)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
error: errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
object: res
|
||||
}
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is ModelInfer<Model> {
|
||||
return isObject(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* alias of {@link Schema}
|
||||
*/
|
||||
export const s = Schema
|
3
src/Schema/utils.ts
Normal file
3
src/Schema/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isNull(value: unknown): value is undefined | null {
|
||||
return typeof value === 'undefined' || value === null
|
||||
}
|
@ -6,6 +6,7 @@ interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
|
||||
block?: boolean
|
||||
suffix?: string
|
||||
prefix?: string
|
||||
parentClass?: string
|
||||
}
|
||||
|
||||
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix')
|
||||
@ -16,7 +17,7 @@ if (baseProps.type === 'textarea') {
|
||||
---
|
||||
|
||||
<!-- input wrapper -->
|
||||
<label class:list={['parent', {'w-full': Astro.props.block}]}>
|
||||
<label class:list={['parent', {'w-full': Astro.props.block}, Astro.props.parentClass]}>
|
||||
{Astro.props.label && (
|
||||
<div class="label">{Astro.props.label}</div>
|
||||
)}
|
||||
@ -26,7 +27,7 @@ if (baseProps.type === 'textarea') {
|
||||
<p class="prefix">{Astro.props.prefix}</p>
|
||||
)}
|
||||
{Astro.props.type === 'textarea' && (
|
||||
<textarea data-component="textarea" class="textarea transition-[min-height]" {...baseProps} />
|
||||
<textarea data-component="textarea" class:list={["textarea transition-[min-height]", baseProps.class]} {...baseProps} />
|
||||
) || (
|
||||
<input {...baseProps as any} />
|
||||
)}
|
||||
|
58
src/components/global/Select/SelectController.ts
Normal file
58
src/components/global/Select/SelectController.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import Component from 'libs/Component'
|
||||
import DOMElement from 'libs/DOMElement'
|
||||
|
||||
export function updateSelectValues(el: HTMLInputElement, values: Array<string | number | { title: string | number, description?: string | number | null, value?: string | number, image?: string }>, options?: { clearText?: boolean } = { clearText: false }) {
|
||||
const list = new DOMElement(el.parentElement?.querySelector('ul')!)
|
||||
const label = el.parentElement?.parentElement!
|
||||
label.dataset.component = 'select'
|
||||
const name = el.dataset.name!
|
||||
|
||||
const displayValue = el.value
|
||||
if (options.clearText && !values.includes(displayValue)) {
|
||||
el.value = ''
|
||||
}
|
||||
|
||||
list.removeChilds()
|
||||
|
||||
for (let item of values) {
|
||||
if (typeof item !== 'object') {
|
||||
item = { title: item }
|
||||
}
|
||||
|
||||
list.appendChild(
|
||||
new DOMElement('li')
|
||||
.addClass('flex')
|
||||
.appendChild(
|
||||
new DOMElement('label')
|
||||
.addClass('flex', 'gap-2')
|
||||
.appendChild(
|
||||
new DOMElement('input')
|
||||
.attr('width', 14)
|
||||
.attr('height', 14)
|
||||
.attr('hidden', !el.dataset.multiple)
|
||||
.attr('type', el.dataset.multiple ? 'checkbox' : 'radio')
|
||||
.attr('name', name)
|
||||
.attr('value', item.value ?? item.title)
|
||||
.attr('checked', ''),
|
||||
new DOMElement('div')
|
||||
.classList('flex', 'gap-2', 'items-center')
|
||||
.appendChild(
|
||||
item.image ? new DOMElement('img')
|
||||
.attr('width', 24)
|
||||
.attr('height', 24)
|
||||
.classList('rounded-full', 'w-6', 'h-6')
|
||||
.attr('src', item.image) : undefined,
|
||||
new DOMElement('div')
|
||||
.appendChild(
|
||||
new DOMElement('p')
|
||||
.text(item.title.toString())
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Component.load(label)
|
||||
|
||||
}
|
@ -1,22 +1,38 @@
|
||||
---
|
||||
import { objectOmit } from '@dzeio/object-util'
|
||||
import { ChevronDown, X } from 'lucide-astro'
|
||||
|
||||
export interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
|
||||
placeholder?: string
|
||||
label?: string
|
||||
iconLeft?: any
|
||||
/**
|
||||
* clear the selected value by clicking the xross on the left of the icon
|
||||
*/
|
||||
clearable?: boolean
|
||||
iconRight?: any
|
||||
block?: boolean
|
||||
suffix?: string
|
||||
prefix?: string
|
||||
filterable?: boolean
|
||||
/**
|
||||
* mutually exclusive with `autocomplete`
|
||||
*/
|
||||
multiple?: boolean
|
||||
options: Array<string | number | {value?: string, title: string | number, description?: string | number | null}>
|
||||
/**
|
||||
* mutually exclusive with `multiple`
|
||||
*/
|
||||
autocomplete?: boolean
|
||||
debug?: boolean
|
||||
options?: Array<string | number | { title: string | number, description?: string | number | null, value?: string | number, image?: string }>
|
||||
}
|
||||
|
||||
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix', 'options')
|
||||
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix', 'options', 'multiple', 'debug', 'clearable')
|
||||
const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.props.value?.toString()?.split(',') ?? [])
|
||||
---
|
||||
|
||||
<!-- input wrapper -->
|
||||
<label data-component="select" data-options={JSON.stringify(Astro.props.options)} class:list={['parent', {'w-full': Astro.props.block}]}>
|
||||
<label data-component="select" class:list={['parent', 'select', {'w-full': Astro.props.block}]}>
|
||||
{Astro.props.label && (
|
||||
<div class="label">{Astro.props.label}</div>
|
||||
)}
|
||||
@ -25,27 +41,48 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
|
||||
{Astro.props.prefix && (
|
||||
<p class="prefix">{Astro.props.prefix}</p>
|
||||
)}
|
||||
<input readonly {...objectOmit(baseProps, 'name') as any} />
|
||||
<ul class="list hidden">
|
||||
{Astro.props.options.map((it) => {
|
||||
{Astro.props.iconLeft && (
|
||||
<Astro.props.iconLeft class="prefix" />
|
||||
)}
|
||||
<input data-multiple={Astro.props.multiple} data-name={Astro.props.name} readonly={!Astro.props.autocomplete} {...objectOmit(baseProps, 'name') as any} />
|
||||
<ul class:list={['list hidden', {'hover:block': Astro.props.multiple}]}>
|
||||
{Astro.props.options?.map((it) => {
|
||||
if (typeof it !== 'object') {
|
||||
it = {title: it}
|
||||
}
|
||||
const itemValue = it.value ?? it.title
|
||||
const checked = values.includes(itemValue)
|
||||
return (
|
||||
<li>
|
||||
<li class="flex">
|
||||
<label class="flex gap-2">
|
||||
<input width={14} height={14} hidden={!Astro.props.multiple} type={Astro.props.multiple ? 'checkbox' : 'radio'} name={baseProps.name} value={itemValue} checked={checked} />
|
||||
<p>{it.title}</p>
|
||||
{it.description && (
|
||||
<p class="desc">{it.description}</p>
|
||||
)}
|
||||
<input width={14} height={14} hidden={!Astro.props.multiple && !Astro.props.debug} type={Astro.props.multiple ? 'checkbox' : 'radio'} name={baseProps.name} value={itemValue} checked={checked} />
|
||||
<div class="flex gap-2 items-center">
|
||||
{it.image && (
|
||||
<img loading="lazy" src={it.image} width={24} height={24} class="rounded-full h-6 w-6" />
|
||||
)}
|
||||
<div>
|
||||
<p>{it.title}</p>
|
||||
{it.description && (
|
||||
<p class="desc">{it.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{Astro.props.clearable && (
|
||||
<label class="clearable">
|
||||
<X class="prefix" />
|
||||
<input hidden type="radio" name={Astro.props.name} value="" />
|
||||
</label>
|
||||
)}
|
||||
{Astro.props.iconRight && (
|
||||
<Astro.props.iconRight class="suffix" />
|
||||
) || (
|
||||
<ChevronDown class="suffix peer-focus:rotate-180" />
|
||||
)}
|
||||
{Astro.props.suffix && (
|
||||
<p class="suffix">{Astro.props.suffix}</p>
|
||||
)}
|
||||
@ -54,18 +91,22 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
|
||||
|
||||
<style>
|
||||
.parent {
|
||||
@apply flex flex-col cursor-text gap-2
|
||||
@apply flex flex-col cursor-text gap-2 min-w-9
|
||||
}
|
||||
|
||||
.suffix, .prefix {
|
||||
@apply select-none font-light text-gray-400
|
||||
}
|
||||
.input, .textarea {
|
||||
@apply px-4 w-full bg-gray-100 rounded-lg border-gray-200 min-h-0 border flex items-center gap-2 py-2
|
||||
@apply px-4 w-full bg-gray-100 dark:bg-gray-700 rounded-lg border-gray-200 dark:border-slate-900 min-h-0 border flex items-center gap-2 py-2 outline outline-0 transition-all duration-100
|
||||
}
|
||||
|
||||
.input:focus-within {
|
||||
@apply outline outline-4 outline-primary-300/50 border-primary-300
|
||||
}
|
||||
|
||||
.input > input {
|
||||
@apply w-full bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none text-black
|
||||
@apply w-full bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none text-black dark:text-white
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@ -73,52 +114,119 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
|
||||
}
|
||||
|
||||
.list {
|
||||
@apply absolute top-full mt-2 z-10 bg-gray-50 rounded-lg border border-gray-300 overflow-hidden
|
||||
@apply max-h-96 overflow-y-auto absolute top-full left-0 mt-2 z-10 w-full bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-300 overflow-hidden
|
||||
}
|
||||
.input > input:focus + ul, ul:hover {
|
||||
.input > input:focus + ul, ul:active {
|
||||
@apply block
|
||||
}
|
||||
ul :global(label) {
|
||||
@apply px-4 py-2 cursor-pointer
|
||||
}
|
||||
ul :global(li) {
|
||||
@apply px-4 py-2 flex flex-col gap-1 hover:bg-gray-100 cursor-pointer
|
||||
@apply flex-col gap-1 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer
|
||||
}
|
||||
ul :global(li p) {
|
||||
@apply text-gray-600
|
||||
@apply text-gray-600 dark:text-gray-50
|
||||
}
|
||||
ul :global(li p.desc) {
|
||||
@apply text-sm font-light
|
||||
}
|
||||
|
||||
input:placeholder-shown ~ .clearable {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Component from 'libs/Component'
|
||||
|
||||
|
||||
|
||||
Component.addComponent<HTMLElement>('select', (it) => {
|
||||
const displayInput = it.querySelector<HTMLInputElement>('input[readonly]')
|
||||
const displayInput = it.querySelector<HTMLInputElement>('.input > input')!
|
||||
const inputs = Array.from(it.querySelectorAll<HTMLInputElement>('li input'))
|
||||
const entries = Array.from(it.querySelectorAll<HTMLInputElement>('li')).map((it) => ({
|
||||
li: it,
|
||||
input: it.querySelector('input')!,
|
||||
title: it.querySelector('p')!
|
||||
}))
|
||||
const list = it.querySelector('ul')
|
||||
if (!list || !displayInput) {
|
||||
return
|
||||
}
|
||||
function updateSelectValue() {
|
||||
const checkedValues = inputs.filter((it) => it.checked).map((it) => it.value)
|
||||
displayInput.value = checkedValues.toString()
|
||||
function updateSelectValue(updateText = true, blur = true) {
|
||||
const checkedValues = entries.filter((it) => it.input.checked)
|
||||
|
||||
if (updateText) {
|
||||
displayInput.value = checkedValues.map((it) => it.title.innerText).join(', ')
|
||||
displayInput.dispatchEvent(new Event('change'))
|
||||
if (blur) {
|
||||
displayInput?.blur()
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
entry.li.classList.remove('hidden')
|
||||
entry.li.classList.add('flex')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
const values = displayInput.getAttribute('value')?.split(',') ?? []
|
||||
console.log('values', values)
|
||||
if (values.length > 0) {
|
||||
const checkbox = list.querySelector(`input[value="${value}"]`)
|
||||
if (checkbox) {
|
||||
checkbox.checked = true
|
||||
const defaultValue = (displayInput.value ?? displayInput.getAttribute('value'))
|
||||
const values = defaultValue?.split(',') ?? []
|
||||
if (values.length > 0 && values[0] !== '') {
|
||||
let valueText = ''
|
||||
for (const value of values) {
|
||||
const checkbox = entries.find((entry) => entry.input.value === value)
|
||||
if (checkbox) {
|
||||
checkbox.input.checked = true
|
||||
valueText += checkbox.title.innerText + ','
|
||||
}
|
||||
}
|
||||
console.log(valueText)
|
||||
if (valueText) {
|
||||
displayInput.value = valueText.slice(0, -1)
|
||||
}
|
||||
}
|
||||
list.querySelectorAll('li input').forEach((listItem: HTMLInputElement) => {
|
||||
console.log('')
|
||||
listItem.addEventListener('change', () => {
|
||||
console.log(listItem, 'changed to', listItem.checked)
|
||||
updateSelectValue()
|
||||
if (!displayInput.readOnly) {
|
||||
displayInput.addEventListener('input', () => {
|
||||
const value = displayInput.value
|
||||
for (const entry of entries) {
|
||||
if (entry.title.innerText.toLowerCase().includes(value.toLowerCase())) {
|
||||
entry.li.classList.remove('hidden')
|
||||
entry.li.classList.add('flex')
|
||||
} else {
|
||||
entry.li.classList.remove('flex')
|
||||
entry.li.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
const isMultipleAndAutocomplete = !displayInput.readOnly && typeof displayInput.dataset.multiple === 'string'
|
||||
if (isMultipleAndAutocomplete) {
|
||||
let isSelecting = false
|
||||
displayInput.addEventListener('focus', () => {
|
||||
if (!isSelecting) {
|
||||
displayInput.value = ''
|
||||
}
|
||||
})
|
||||
displayInput.addEventListener('blur', () => {
|
||||
updateSelectValue(!isSelecting)
|
||||
})
|
||||
it.addEventListener('pointerenter', () => {
|
||||
isSelecting = true
|
||||
})
|
||||
it.addEventListener('pointerleave', () =>{
|
||||
isSelecting = false
|
||||
setTimeout(() => {
|
||||
if (!isSelecting) {
|
||||
updateSelectValue(true, false)
|
||||
}
|
||||
}, 200);
|
||||
})
|
||||
} else {
|
||||
it.querySelectorAll<HTMLInputElement>('input[type="checkbox"],input[type="radio"]').forEach((listItem: HTMLInputElement) => {
|
||||
listItem.addEventListener('click', () => {
|
||||
updateSelectValue()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
34
src/env.d.ts
vendored
34
src/env.d.ts
vendored
@ -1,6 +1,7 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
|
||||
/**
|
||||
* Environment variables declaration
|
||||
*/
|
||||
@ -21,5 +22,36 @@ declare namespace App {
|
||||
/**
|
||||
* Middlewares variables
|
||||
*/
|
||||
interface Locals {}
|
||||
interface Locals { }
|
||||
}
|
||||
|
||||
declare namespace astroHTML.JSX {
|
||||
interface HTMLAttributes {
|
||||
'hyp:trigger'?: string
|
||||
'hyp:multiple'?: boolean
|
||||
'hyp:path'?: string
|
||||
|
||||
'hyp:action'?: string
|
||||
[key?: `hyp:action${number}`]: string
|
||||
|
||||
// deprecated ones
|
||||
'data-input'?: string
|
||||
'data-output'?: string
|
||||
'data-trigger'?: string
|
||||
'data-path'?: string
|
||||
'data-multiple'?: true
|
||||
|
||||
//
|
||||
// TEMPLATE
|
||||
//
|
||||
|
||||
'hyp:set'?: string
|
||||
'hyp:loop'?: string
|
||||
'hyp:if'?: string
|
||||
'hyp:ifnot'?: string
|
||||
|
||||
// deprecated
|
||||
'data-attribute'?: string
|
||||
'data-loop'?: string
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ export interface Props extends HeadProps {
|
||||
|
||||
<script>
|
||||
import Component from 'libs/Component'
|
||||
import Hyperion from 'libs/Hyperion'
|
||||
import Hyperion, { utils } from 'hyperions'
|
||||
|
||||
Hyperion.setup()
|
||||
.on('error', ({ error }) => {
|
||||
@ -28,4 +28,27 @@ import Hyperion from 'libs/Hyperion'
|
||||
.on('htmlChange', ({ newElement }) => {
|
||||
Component.load(newElement)
|
||||
})
|
||||
.addAction('clear', ({ origin, value }) => {
|
||||
const item = utils.locate<HTMLFormElement>(origin, value!)
|
||||
if (item) {
|
||||
item.remove()
|
||||
}
|
||||
})
|
||||
.addAction('load', ({ origin, value }) => {
|
||||
const item = utils.locate<HTMLFormElement>(origin, value!)!
|
||||
const formData = new FormData(item);
|
||||
const res: Record<string, any> = {}
|
||||
formData.forEach((value, key) => {
|
||||
const multi = item.querySelector<HTMLInputElement>(`input[type][name="${key}"]`)?.type === "checkbox";
|
||||
if (multi) {
|
||||
res[key] = formData.getAll(key);
|
||||
} else {
|
||||
res[key] = value;
|
||||
}
|
||||
})
|
||||
return {
|
||||
data: res
|
||||
}
|
||||
})
|
||||
.load()
|
||||
</script>
|
||||
|
241
src/libs/DOMElement.ts
Normal file
241
src/libs/DOMElement.ts
Normal file
@ -0,0 +1,241 @@
|
||||
type Tags = keyof HTMLElementTagNameMap
|
||||
|
||||
export default class DOMElement<T extends HTMLElement = HTMLElement> {
|
||||
|
||||
public item: T
|
||||
|
||||
public constructor(tagName: Tags | T, options?: ElementCreationOptions) {
|
||||
if (tagName instanceof HTMLElement) {
|
||||
this.item = tagName
|
||||
return
|
||||
}
|
||||
this.item = document.createElement(tagName, options) as any
|
||||
}
|
||||
|
||||
public static create<K extends Tags>(
|
||||
tagName: K,
|
||||
options?: ElementCreationOptions
|
||||
): DOMElement<HTMLElementTagNameMap[K]>
|
||||
public static create(
|
||||
tagName: string,
|
||||
options?: ElementCreationOptions
|
||||
): DOMElement<HTMLElement> {
|
||||
return new DOMElement(tagName as Tags, options)
|
||||
}
|
||||
|
||||
|
||||
public static get<GT extends HTMLElement = HTMLElement>(
|
||||
query: string | GT,
|
||||
source?: HTMLElement | DOMElement
|
||||
): DOMElement<GT> | undefined {
|
||||
if (!(query instanceof HTMLElement)) {
|
||||
const tmp = (source instanceof DOMElement ? source.item : source || document).querySelector<GT>(query)
|
||||
if (!tmp) {
|
||||
return undefined
|
||||
}
|
||||
return new DOMElement(tmp)
|
||||
}
|
||||
return new DOMElement(query)
|
||||
}
|
||||
|
||||
public static getAll<GT extends HTMLElement = HTMLElement>(
|
||||
query: string,
|
||||
source?: HTMLElement | DOMElement
|
||||
): Array<DOMElement<GT>> | undefined {
|
||||
const tmp = (source instanceof DOMElement ? source.item : source || document).querySelectorAll<GT>(query)
|
||||
let items: Array<DOMElement<GT>> = []
|
||||
tmp.forEach((it) => items.push(DOMElement.get(it)))
|
||||
return items
|
||||
}
|
||||
|
||||
public on<K extends keyof HTMLElementEventMap>(
|
||||
type: K,
|
||||
listener: (this: T, ev: HTMLElementEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): this
|
||||
public on(
|
||||
type: string,
|
||||
listener: (this: T, ev: Event) => void,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): this
|
||||
public on(
|
||||
type: string,
|
||||
listener: (this: T, ev: Event) => void,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) {
|
||||
this.item.addEventListener(type, listener, options)
|
||||
return this
|
||||
}
|
||||
|
||||
public off<K extends keyof HTMLElementEventMap>(
|
||||
type: K,
|
||||
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void
|
||||
) {
|
||||
this.item.removeEventListener(type, listener)
|
||||
return this
|
||||
}
|
||||
|
||||
public text(): string
|
||||
public text(val: string): this
|
||||
public text(val?: string) {
|
||||
if (typeof val !== 'undefined') {
|
||||
this.item.innerText = val
|
||||
return this
|
||||
}
|
||||
return this.item.innerText
|
||||
}
|
||||
|
||||
public html(): string
|
||||
public html(val: string): this
|
||||
public html(val?: string) {
|
||||
if (typeof val !== 'undefined') {
|
||||
this.item.innerHTML = val
|
||||
return this
|
||||
}
|
||||
return this.item.innerHTML
|
||||
}
|
||||
|
||||
public addClass(...classes: Array<string>) {
|
||||
this.item.classList.add(...classes)
|
||||
return this
|
||||
}
|
||||
|
||||
public setClass(...classes: Array<string>) {
|
||||
this.item.classList.forEach((cls) => {
|
||||
if (!classes.includes(cls)) {
|
||||
this.item.classList.remove(cls)
|
||||
}
|
||||
})
|
||||
this.addClass(...classes)
|
||||
return this
|
||||
}
|
||||
|
||||
public classList(...classes: Array<string>): this
|
||||
public classList(): Array<string>
|
||||
public classList(...classes: Array<string>) {
|
||||
if (!classes) {
|
||||
const res: Array<string> = []
|
||||
this.item.classList.forEach((el) => res.push(el))
|
||||
return res
|
||||
}
|
||||
return this.setClass(...classes)
|
||||
}
|
||||
|
||||
public toggleClass(...classes: Array<string>) {
|
||||
for (const classe of classes) {
|
||||
this.item.classList.toggle(classe)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
public removeClass(...classes: Array<string>) {
|
||||
this.item.classList.remove(...classes)
|
||||
return this
|
||||
}
|
||||
|
||||
public emit<E extends keyof HTMLElementEventMap>(event: E): this
|
||||
public emit(event: string): this
|
||||
public emit(event: string): this {
|
||||
if (event in this.item) {
|
||||
(this.item as any)[event]()
|
||||
return this
|
||||
}
|
||||
this.item.dispatchEvent(new Event(event))
|
||||
return this
|
||||
}
|
||||
|
||||
public attr(key: string): string | null
|
||||
public attr(key: string, value: string | number | null): this
|
||||
public attr(key: keyof T, value: boolean): this
|
||||
public attr(key: string | keyof T, value?: string | number | boolean | null): this | string | null {
|
||||
if (typeof value === 'undefined') {
|
||||
return this.item.getAttribute(key as string)
|
||||
}
|
||||
if (value === null) {
|
||||
this.item.removeAttribute(key as string)
|
||||
return this
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
this.item[key as 'draggable'] = value
|
||||
return this
|
||||
}
|
||||
this.item.setAttribute(key as string, value.toString())
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
public data(key: string): string | null
|
||||
public data(key: string, value: string | null): this
|
||||
public data(key: string, value?: string | null): this | string | null {
|
||||
if (key in this.item.dataset) {
|
||||
return this.item.dataset[key]
|
||||
}
|
||||
return this.attr(`data-${key}`, value)
|
||||
}
|
||||
|
||||
public style(key: keyof CSSStyleDeclaration): CSSStyleDeclaration[typeof key]
|
||||
public style(key: keyof CSSStyleDeclaration, value: CSSStyleDeclaration[typeof key]): this
|
||||
public style(key: keyof CSSStyleDeclaration, value?: CSSStyleDeclaration[typeof key]) {
|
||||
if (typeof value === 'undefined') {
|
||||
return this.item.style[key]
|
||||
}
|
||||
this.item.style[key as any] = value as string
|
||||
return this
|
||||
}
|
||||
|
||||
public exist() {
|
||||
return !!this.item
|
||||
}
|
||||
|
||||
public placeBefore(item: DOMElement | HTMLElement) {
|
||||
if (item instanceof DOMElement) {
|
||||
item = item.item
|
||||
}
|
||||
const parent = item.parentElement
|
||||
if (!parent) {
|
||||
throw new Error('can\'t place DOMElement before item because it has no parent')
|
||||
}
|
||||
parent.insertBefore(this.item, item)
|
||||
return this
|
||||
}
|
||||
|
||||
public placeAsChildOf(item: DOMElement | HTMLElement) {
|
||||
if (item instanceof DOMElement) {
|
||||
item = item.item
|
||||
}
|
||||
|
||||
item.appendChild(this.item)
|
||||
return this
|
||||
}
|
||||
|
||||
public place(verb: 'before' | 'asChildOf', item: DOMElement | HTMLElement) {
|
||||
if (verb === 'before') {
|
||||
return this.placeBefore(item)
|
||||
} else {
|
||||
return this.placeAsChildOf(item)
|
||||
}
|
||||
}
|
||||
|
||||
public appendChild(...items: Array<DOMElement | HTMLElement | undefined>) {
|
||||
for (let item of items) {
|
||||
if (typeof item === 'undefined') {
|
||||
continue
|
||||
}
|
||||
if (item instanceof DOMElement) {
|
||||
item = item.item
|
||||
}
|
||||
this.item.appendChild(item)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
public removeChilds() {
|
||||
while (this.item.firstChild) {
|
||||
this.item.firstChild.remove()
|
||||
}
|
||||
}
|
||||
|
||||
public remove() {
|
||||
this.item.remove()
|
||||
}
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
# Hyperion
|
||||
|
||||
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
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm i hyperion
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import Hyperion from 'hyperion'
|
||||
Hyperion.setup()
|
||||
```
|
||||
|
||||
```html
|
||||
<button
|
||||
data-trigger="" <-- How the input will be triggered
|
||||
|
||||
data-input="/api/v1/" <-- get from a remote source (result MUST be in JSON)
|
||||
data-input="delete:/api/v1/" <-- the method is chosen by using a method
|
||||
data-input="run:action" <-- will run with param0 being the HTMLElement & param1 being the input data in JSON (form only)
|
||||
|
||||
-- IO --
|
||||
|
||||
data-path="path" <-- run the output on a child instead of the base object
|
||||
data-multiple <-- (ONLY if response object is an array) will display multiple elements instead of one
|
||||
|
||||
data-output="template location|body|this{location} inner|outer|append" <-- Will fill the template, and display it in the location (inner,outer define if it replace or is a child)
|
||||
data-output="run:action" <-- will run with param0 being the HTMLElement & param1 being the data in JSON
|
||||
data-output="hyp:query" <-- Will run another Hyperion element by the query
|
||||
></button>
|
||||
```
|
||||
|
||||
- Template
|
||||
it MUST only have one child
|
||||
```html
|
||||
<template id="pokemon">
|
||||
<p
|
||||
data-attribute="key" <-- set the inner text of the element
|
||||
data-attribute="html:key" <-- set the inner HTML of the element
|
||||
data-attribute="outertext:key" <-- set the outer text of the element
|
||||
data-attribute="outerhtml:key" <-- set the outer HTML of the element
|
||||
data-attribute="any-other-attribute:key" <-- set the attribute of the element
|
||||
|
||||
data-loop="key" <-- child elements will loop through each items of the array defined by `key`
|
||||
></p>
|
||||
</template>
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
### Attributes
|
||||
|
||||
one of the `data-input` or `data-output` MUST be set so that everything work.
|
||||
|
||||
#### data-trigger: (optionnal)
|
||||
the different trigger available (default: 'submit' for <form> else 'click' for the rest) (multiple can be used)
|
||||
- load: action is run on start
|
||||
- once: only run Hyperion once (manual trigger can still happens if `force` is `true`)
|
||||
- `after:xx`: trigger will defer until xx time of ms passed (allow to dedup requests)
|
||||
- HTMLListenerEvent: any HTMLElement event available
|
||||
|
||||
#### data-input: (optionnal)
|
||||
if data-input is not set it will directly got for data-output with an empty object
|
||||
- url: will query the URL using a GET method for JSON data
|
||||
- `method:url`: will query the URL using the method for JSON data
|
||||
- `run:action`: Get informations by running an action
|
||||
|
||||
#### data-path: (optionnal)
|
||||
a Subpath of the input data to lessen strain on the output
|
||||
|
||||
#### data-multiple: (optionnal)
|
||||
if the input is an array and data-multiple is set, the output will be using array
|
||||
|
||||
#### data-output: (optionnal)
|
||||
- template: The template used to display
|
||||
- location: the location to display the element (default: this)
|
||||
note: by using `this` as a prefix it will query relative to the current element (ex: `this#pouet`)
|
||||
- replace|child: replace or append as child (default: child)
|
||||
|
||||
|
||||
### Actions
|
||||
|
||||
Actions are elements defined in Hyperion that run code to fetch informations or run custom code
|
||||
|
||||
there is two types of actions:
|
||||
- input Action
|
||||
An input Action MUST return a JSON object and will have access to the HTMLElement that was triggered
|
||||
`(element?: HTMLElement, input?: object) => Promise<object> | object`
|
||||
the element can be omitted when trigger is done remotely
|
||||
the input can be available depending on the source
|
||||
|
||||
- output Action
|
||||
`(element: HTMLElement, output: object) => Promise<void> | void`
|
||||
the output is the data fetched by the input
|
||||
|
||||
example output Action
|
||||
`popup`
|
||||
will search a popup template by using an additionnal attribute `data-template` and fill the elements to display a popup
|
||||
|
||||
builtin output actions
|
||||
- `reload`: Reload the current page
|
||||
|
||||
### Hyperion Class
|
||||
|
||||
- Static
|
||||
- setup()
|
||||
- Methods
|
||||
- on('trigger', ({ target: HTMLElement, trigger: string, force: boolean }) => void)
|
||||
- on('htmlChanged', ({ rootElement: HTMLElement }) => void)
|
||||
- on('error', ({ error: Error }) => void)
|
||||
- trigger('trigger', HTMLElement, options?: { force: boolean })
|
||||
- addInputAction(action)
|
||||
- addOutputAction(action)
|
||||
- fillTemplate(template: HTMLTemplateElement, data: object) => HTMLElement
|
||||
|
||||
## Examples
|
||||
|
||||
### pushing changes of a textarea to the remote server
|
||||
|
||||
```html
|
||||
<textarea data-trigger="keyup after:200" data-input="post:/api/v1/text" name="text">
|
||||
base text
|
||||
</textarea>
|
||||
```
|
||||
|
||||
It will send this as a POST request to the url `/api/v1/text` with the body below
|
||||
```json
|
||||
{"text": "base text"}
|
||||
```
|
@ -1,662 +0,0 @@
|
||||
import { isObject, objectGet, objectLoop } from '@dzeio/object-util'
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>
|
||||
|
||||
type InputAction = (element?: HTMLElement, input?: object) => MaybePromise<object>
|
||||
type OutputAction = (element: HTMLElement, output: object) => MaybePromise
|
||||
|
||||
interface Options {
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
interface Events {
|
||||
trigger: (ev: {
|
||||
/**
|
||||
* the source element
|
||||
*/
|
||||
target: HTMLElement,
|
||||
|
||||
/**
|
||||
* the trigger that triggered
|
||||
*/
|
||||
trigger: string,
|
||||
|
||||
/**
|
||||
* is the trigger forced or not
|
||||
*/
|
||||
force: boolean
|
||||
}) => MaybePromise
|
||||
htmlChange: (ev: { newElement: HTMLElement }) => MaybePromise
|
||||
error: (ev: { error: Error, params?: object | undefined }) => MaybePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @author Florian Bouillon <contact@avior.me>
|
||||
* @version 2.0.0
|
||||
* @license MIT
|
||||
*/
|
||||
export default class Hyperion {
|
||||
private static instance: Hyperion
|
||||
private constructor() {
|
||||
this.init()
|
||||
}
|
||||
|
||||
private events: Partial<Record<keyof Events, Array<Events[keyof Events]>>> = {}
|
||||
private inputActions: Record<string, InputAction> = {}
|
||||
private outputActions: Record<string, OutputAction> = {
|
||||
reload() {
|
||||
window.location.reload()
|
||||
},
|
||||
debug(el, out) {
|
||||
console.log('Output launched !', el, out)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* setup the library for usage
|
||||
*/
|
||||
public static setup() {
|
||||
if (!Hyperion.instance) {
|
||||
Hyperion.instance = new Hyperion()
|
||||
}
|
||||
return Hyperion.instance
|
||||
}
|
||||
|
||||
public addOutputAction(action: string, ev: OutputAction | null) {
|
||||
if (ev === null) {
|
||||
delete this.outputActions[action]
|
||||
return this
|
||||
}
|
||||
this.outputActions[action] = ev
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* add an event to Hyperion
|
||||
*
|
||||
* @param event the event to register on
|
||||
* @param ev the event that will run
|
||||
*/
|
||||
public on<T extends keyof Events>(event: T, ev: Events[T]): this {
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
this.events[event]?.push(ev)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* manually trigger an element
|
||||
*
|
||||
* @param element the element to trigger
|
||||
*/
|
||||
public trigger(element: HTMLElement): this {
|
||||
this.processInput(element)
|
||||
return this
|
||||
}
|
||||
|
||||
private emit<T extends keyof Events>(ev: T, params: Parameters<Events[T]>[0]) {
|
||||
const listeners = (this.events[ev] ?? []) as Array<Events[T]>
|
||||
for (const listener of listeners) {
|
||||
listener(params as any)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* initialise hyperion
|
||||
* @param base the base to query from
|
||||
*/
|
||||
private init(base?: HTMLElement) {
|
||||
// setup on itself when possible
|
||||
if (base && (base.dataset.output || base.dataset.input)) {
|
||||
this.setupTrigger(base)
|
||||
}
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
(base ?? document).querySelectorAll<HTMLElement>('[data-output],[data-input]').forEach((it) => {
|
||||
this.setupTrigger(it)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupTrigger(it: HTMLElement) {
|
||||
// get the trigger action
|
||||
const isForm = it.tagName === 'FORM'
|
||||
const triggers: Array<string> = []
|
||||
|
||||
// the triggering options
|
||||
const options: {
|
||||
once?: boolean
|
||||
load?: boolean
|
||||
after?: number
|
||||
} = {}
|
||||
|
||||
// handle options
|
||||
const splitted = this.betterSplit(it.dataset.trigger ?? '')
|
||||
for (const item of splitted) {
|
||||
const { prefix, value } = this.decodeParam(item)
|
||||
switch (prefix ?? value) {
|
||||
case 'once':
|
||||
options.once = true
|
||||
break
|
||||
case 'load':
|
||||
options.load = true
|
||||
break
|
||||
case 'after':
|
||||
options.after = Number.parseInt(value, 10)
|
||||
break
|
||||
default:
|
||||
triggers.push(item)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (triggers.length === 0) {
|
||||
triggers.push(isForm ? 'submit' : 'click')
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
|
||||
// the triggering function
|
||||
const fn = (ev?: Event) => {
|
||||
if (isForm && ev) {
|
||||
ev.preventDefault()
|
||||
}
|
||||
|
||||
// handle event only happening once
|
||||
if (options.once) {
|
||||
for (const trigger of triggers) {
|
||||
it.removeEventListener(trigger, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// special case for option `after:xxx`
|
||||
if (options.after) {
|
||||
if (timeout) { // remove existing timer
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
|
||||
// set a new timer
|
||||
timeout = setTimeout(() => {
|
||||
// process
|
||||
this.processInput(it)
|
||||
timeout = undefined
|
||||
}, options.after)
|
||||
return
|
||||
}
|
||||
|
||||
// run it
|
||||
this.processInput(it)
|
||||
}
|
||||
|
||||
// event can also manually be triggered using `hyperion:trigger`
|
||||
for (const trigger of triggers) {
|
||||
it.addEventListener(trigger, fn)
|
||||
}
|
||||
|
||||
// on load run instantly
|
||||
if (options.load) {
|
||||
this.processInput(it)
|
||||
}
|
||||
}
|
||||
|
||||
private async processInput(it: HTMLElement, data?: object, options?: Options): Promise<void> {
|
||||
// get the input
|
||||
let input = it.dataset.input
|
||||
|
||||
/**
|
||||
* Setup Params
|
||||
*/
|
||||
const params: object = data ?? {}
|
||||
|
||||
|
||||
// parse form values into input params
|
||||
if (it.tagName === 'FORM') {
|
||||
const formData = new FormData(it as HTMLFormElement)
|
||||
formData.forEach((value, key) => {
|
||||
params[key] = value
|
||||
})
|
||||
// parse input value into input param
|
||||
} else if (it.tagName === 'INPUT' && (it as HTMLInputElement).name) {
|
||||
params[(it as HTMLInputElement).name] = (it as HTMLInputElement).value
|
||||
}
|
||||
|
||||
if (it.dataset.params) {
|
||||
const exchange = this.betterSplit(it.dataset.params)
|
||||
for (const item of exchange) {
|
||||
const { prefix, value } = this.decodeParam(item)
|
||||
if (!prefix) {
|
||||
continue
|
||||
}
|
||||
params[prefix] = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// if data-input is not set goto output
|
||||
if (!input) {
|
||||
return this.processOutput(it, params, options)
|
||||
}
|
||||
|
||||
const { prefix, value } = this.decodeParam(input)
|
||||
|
||||
// handle running an input Action
|
||||
if (prefix === 'run') {
|
||||
const inputAction = this.inputActions[value]
|
||||
if (!inputAction) {
|
||||
throw this.makeError(
|
||||
it,
|
||||
`Input Action not found (${value}), you might need to add it using Hyperion.addInputAction()`
|
||||
)
|
||||
}
|
||||
|
||||
const res = await inputAction(it, params)
|
||||
|
||||
return this.processOutput(it, res, options)
|
||||
}
|
||||
|
||||
let method = 'GET'
|
||||
|
||||
if (prefix) {
|
||||
method = prefix.toUpperCase()
|
||||
input = value
|
||||
}
|
||||
|
||||
console.log(method, input)
|
||||
|
||||
const url = new URL(input, window.location.href)
|
||||
let body: string | FormData | null = null
|
||||
|
||||
if (method === 'GET') {
|
||||
console.log('going into GET mode')
|
||||
objectLoop(params, (value, key) => {
|
||||
url.searchParams.set(key, value)
|
||||
})
|
||||
} else if (it.getAttribute('enctype') === 'multipart/form-data') {
|
||||
const formData = new FormData()
|
||||
objectLoop(params, (value, key) => {
|
||||
formData.set(key, value)
|
||||
})
|
||||
body = formData
|
||||
} else {
|
||||
body = JSON.stringify(params)
|
||||
}
|
||||
|
||||
// do the request
|
||||
const res = await fetch(url, {
|
||||
method: method,
|
||||
body: body
|
||||
})
|
||||
|
||||
// handle if the request does not succeed
|
||||
if (res.status >= 400) {
|
||||
throw this.makeError(it, `request returned a ${res.status} error code :(`, { statusCode: res.status })
|
||||
}
|
||||
|
||||
// transform the response into JSON
|
||||
return this.processOutput(it, await res.json(), options)
|
||||
}
|
||||
|
||||
private async processOutput(it: HTMLElement, dataTmp: object, options?: Options): Promise<void> {
|
||||
let data = dataTmp
|
||||
const output = it.dataset.output
|
||||
if (!output) {
|
||||
return
|
||||
}
|
||||
|
||||
const subpath = it.dataset.path
|
||||
if (subpath) {
|
||||
data = objectGet(data, subpath) as object
|
||||
}
|
||||
|
||||
const { prefix, value } = this.decodeParam(output)
|
||||
|
||||
if (prefix === 'run') {
|
||||
const outputActions = this.outputActions[value]
|
||||
if (!outputActions) {
|
||||
throw this.makeError(it, `Output Action Action not found (${value}), you might need to add it using Hyperion.addOutputAction()`)
|
||||
}
|
||||
|
||||
return outputActions(it, data)
|
||||
}
|
||||
|
||||
if (prefix === 'hyp') {
|
||||
const hypItem = document.querySelector<HTMLElement>(value)
|
||||
|
||||
if (!hypItem) {
|
||||
throw this.makeError(it, `Could not find Hyperion element using the query (${value})`)
|
||||
}
|
||||
|
||||
return this.processInput(hypItem, data, options)
|
||||
}
|
||||
|
||||
let [
|
||||
templateQuery,
|
||||
locationQuery = null,
|
||||
placement = 'inner'
|
||||
] = this.betterSplit(output)
|
||||
|
||||
const template = document.querySelector<HTMLTemplateElement>(templateQuery as string)
|
||||
|
||||
if (!template || template.tagName !== 'TEMPLATE') {
|
||||
const msg = !template ? `Template not found using query(${templateQuery})` : `Template is not a template ! (tag is ${template.tagName} while it should be TEMPLATE)`
|
||||
throw this.makeError(it, msg)
|
||||
}
|
||||
|
||||
const placements = ['inner', 'outer', 'append']
|
||||
|
||||
if (locationQuery && placements.includes(locationQuery)) {
|
||||
placement = locationQuery
|
||||
locationQuery = null
|
||||
}
|
||||
|
||||
const isArray = 'multiple' in it.dataset
|
||||
const clones = isArray ? (data as Array<object>).map((it) => this.fillTemplate(template, it)) : [this.fillTemplate(template, data)]
|
||||
|
||||
let location = it
|
||||
if (locationQuery) {
|
||||
if (locationQuery === 'body') {
|
||||
location = document.body
|
||||
} else {
|
||||
let origin = document.body
|
||||
if (locationQuery.startsWith('this')) {
|
||||
origin = it
|
||||
locationQuery = locationQuery.slice(4)
|
||||
}
|
||||
const tmp = origin.querySelector<HTMLElement>(locationQuery)
|
||||
if (!tmp) {
|
||||
throw this.makeError(it, `New location not found (origin: ${origin.tagName}, query: ${locationQuery})`)
|
||||
}
|
||||
location = tmp
|
||||
}
|
||||
}
|
||||
|
||||
if (!location) {
|
||||
throw this.makeError(it, 'location not found :(')
|
||||
}
|
||||
|
||||
switch (placement) {
|
||||
case 'outer': // the clones replace the targetted element
|
||||
location.replaceWith(...clones)
|
||||
// TODO: might be buggy due to shit
|
||||
for (const clone of clones) {
|
||||
this.emit('htmlChange', { newElement: clone })
|
||||
}
|
||||
break;
|
||||
case 'inner': // the clones replace the childs of the targetted element
|
||||
// remove the current childs
|
||||
while (location.firstChild) {
|
||||
location.removeChild(location.firstChild)
|
||||
}
|
||||
// add each clones
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
clones.forEach((it) => {
|
||||
const appendedChild = location.appendChild(it)
|
||||
this.emit('htmlChange', { newElement: appendedChild })
|
||||
})
|
||||
break
|
||||
case 'append': {
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
clones.forEach((it) => {
|
||||
document.body.appendChild(it)
|
||||
this.emit('htmlChange', { newElement: it })
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fill the template from the data given
|
||||
*
|
||||
* @param template the template to fill
|
||||
* @param data the data to filel with
|
||||
* @returns the filled HTMLElement
|
||||
*/
|
||||
public fillTemplate(template: HTMLTemplateElement, data: object): HTMLElement {
|
||||
// clone the template
|
||||
let node = template.content.cloneNode(true) as DocumentFragment | HTMLElement
|
||||
|
||||
if (node.childNodes.length > 1) {
|
||||
const parent = document.createElement('div')
|
||||
parent.appendChild(node)
|
||||
node = parent
|
||||
}
|
||||
|
||||
const clone = node.firstElementChild as HTMLElement
|
||||
|
||||
return this.fill(clone, data)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param el the element to fill
|
||||
* @param data the data to fill it with
|
||||
* @returns the filled element (original changed)
|
||||
*/
|
||||
public fill(el: HTMLElement, data: object) {
|
||||
if (el.dataset.loop) {
|
||||
this.fillLoop(el, data)
|
||||
}
|
||||
|
||||
let childLoop = el.querySelector<HTMLElement>('[data-loop]')
|
||||
while (childLoop) {
|
||||
this.fillLoop(childLoop, data)
|
||||
childLoop = el.querySelector<HTMLElement>('[data-loop]')
|
||||
}
|
||||
|
||||
// go through every elements that has a attribute to fill
|
||||
this.fillAttrs(el, data)
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
el.querySelectorAll<HTMLElement>('[data-attribute],[data-input],[data-output],[data-params]').forEach((it) => this.fillAttrs(it, data))
|
||||
|
||||
// setup the clone to work if it contains Hyperion markup
|
||||
this.init(el)
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
private fillLoop(it: HTMLElement, data: object, context: Array<string | number> = []) {
|
||||
const path = it.dataset.loop
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
const subElement = objectGet(data, path) as Array<any>
|
||||
for (let idx = 0; idx < subElement.length; idx++) {
|
||||
const currentContext = [...context, path, idx]
|
||||
const child = it.cloneNode(true) as HTMLElement
|
||||
child.removeAttribute('data-loop')
|
||||
|
||||
let childLoop = child.querySelector<HTMLElement>('[data-loop]')
|
||||
while (childLoop) {
|
||||
this.fillLoop(childLoop, data, currentContext)
|
||||
childLoop = child.querySelector<HTMLElement>('[data-loop]')
|
||||
}
|
||||
|
||||
this.fillLoop(child, data, currentContext)
|
||||
|
||||
// go through every elements that has a attribute to fill
|
||||
this.fillAttrs(child, data, currentContext)
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
const items = Array.from(child.querySelectorAll<HTMLElement>('[data-attribute],[data-input],[data-output]'))
|
||||
for (const item of items) {
|
||||
this.fillAttrs(item, data, currentContext)
|
||||
}
|
||||
// child.querySelectorAll<HTMLElement>('[data-attribute],[data-input],[data-output]').forEach((it) => this.fillAttrs(it, sub))
|
||||
|
||||
it!.after(child)
|
||||
}
|
||||
|
||||
it.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* fill the attributes to the element
|
||||
* @param it the element to fill
|
||||
* @param data the data link to this element
|
||||
* @returns the filled element
|
||||
*/
|
||||
private fillAttrs(it: HTMLElement, data: object, context: Array<string | number> = []) {
|
||||
// get the raw attribute
|
||||
const attrRaw = it.dataset.attribute
|
||||
|
||||
for (const attr of ['output', 'input', 'trigger', 'params']) {
|
||||
let value = it.dataset[attr]
|
||||
if (!value || !value.includes('{')) {
|
||||
continue
|
||||
}
|
||||
value = this.parseValue(value, data, context)
|
||||
|
||||
it.setAttribute(`data-${attr}`, value)
|
||||
// it.dataset[attr] = value
|
||||
}
|
||||
|
||||
if (typeof attrRaw !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
// parse into an array
|
||||
const attrs: Array<string> = this.betterSplit(attrRaw)
|
||||
|
||||
// loop through each attributes
|
||||
for (const attrRaw of attrs) {
|
||||
const { prefix = 'innerhtml', value } = this.decodeParam(attrRaw)
|
||||
|
||||
// handle processing of string
|
||||
// handle a string with attribute processing
|
||||
const attr = this.parseValue(value, data, context)
|
||||
|
||||
switch (prefix) {
|
||||
case 'html':
|
||||
case 'innerhtml': {
|
||||
it.innerHTML = attr
|
||||
break
|
||||
}
|
||||
case 'text':
|
||||
case 'innertext': {
|
||||
it.innerText = attr
|
||||
break
|
||||
}
|
||||
case 'outerhtml': {
|
||||
it.outerHTML = attr
|
||||
break
|
||||
}
|
||||
case 'outertext': {
|
||||
it.outerText = attr
|
||||
break
|
||||
}
|
||||
default: {
|
||||
it.setAttribute(prefix, attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// idk if necessary but remove the attributes from the final HTML
|
||||
it.removeAttribute('data-attribute')
|
||||
}
|
||||
|
||||
/**
|
||||
* A space centered split but it does not split if inside singlequotes
|
||||
*/
|
||||
private betterSplit(input: string): Array<string> {
|
||||
const attrs: Array<string> = []
|
||||
let quoteCount = 0
|
||||
let current = ''
|
||||
const splitted = input.split('')
|
||||
for (let idx = 0; idx < splitted.length; idx++) {
|
||||
const char = splitted[idx];
|
||||
if (char === '\'' && splitted[idx - 1] !== '\\') {
|
||||
quoteCount += 1
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
private decodeParam(str: string): { prefix?: string, value: string } {
|
||||
const index = str.indexOf(':')
|
||||
|
||||
if (index === -1) {
|
||||
return { value: str }
|
||||
}
|
||||
|
||||
return { prefix: str.slice(0, index), value: str.slice(index + 1) }
|
||||
}
|
||||
|
||||
private parseValue(str: string, data: object, context: Array<string | number>): string {
|
||||
let value = str
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
function findValue(key: string) {
|
||||
if (key.startsWith('this')) {
|
||||
const fromContext = objectGet(data, context)
|
||||
let sliced = key.slice(4)
|
||||
if (sliced.startsWith('.')) {
|
||||
sliced = sliced.slice(1)
|
||||
const tmp = objectGet(fromContext, sliced)
|
||||
if (!tmp) {
|
||||
console.log(3)
|
||||
return key
|
||||
}
|
||||
return tmp
|
||||
} else {
|
||||
console.log(fromContext, data, context)
|
||||
return fromContext
|
||||
}
|
||||
}
|
||||
if (!isObject(data) && (Array.isArray(key) && key.length > 0 || key)) {
|
||||
console.log(str, data, context, key)
|
||||
return key
|
||||
}
|
||||
const res = objectGet(data, key)
|
||||
if (!res) {
|
||||
console.log(2)
|
||||
return key
|
||||
}
|
||||
return res
|
||||
}
|
||||
if (!value.includes('{')) {
|
||||
return findValue(value)
|
||||
} else {
|
||||
let res = /\{(.*?)\}/g.exec(value)
|
||||
while (res && res.length >= 2) {
|
||||
const key = res[1]!
|
||||
value = value.replace(`${res[0]}`, findValue(key))
|
||||
|
||||
res = /\{(.*?)\}/g.exec(value)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
private makeError(el: HTMLElement, message: string, params?: object) {
|
||||
el.dispatchEvent(new CustomEvent('hyperion:error', {
|
||||
detail: {
|
||||
error: message,
|
||||
params: params
|
||||
}
|
||||
}))
|
||||
const error = new Error(message)
|
||||
this.emit('error', { error, params })
|
||||
return error
|
||||
}
|
||||
}
|
23
src/libs/Schema/Items/DzeioLiteral.ts
Normal file
23
src/libs/Schema/Items/DzeioLiteral.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import SchemaItem, { type JSONSchemaItem } from '../SchemaItem'
|
||||
|
||||
export default class DzeioLiteral<T> extends SchemaItem<T> {
|
||||
public constructor(private readonly value: T) {
|
||||
super()
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input === value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is T {
|
||||
return typeof input === typeof this.value
|
||||
}
|
||||
|
||||
public override toJSON(): JSONSchemaItem {
|
||||
return {
|
||||
type: 'literal',
|
||||
params: [this.value as string]
|
||||
}
|
||||
}
|
||||
}
|
93
src/libs/Schema/Items/SchemaArray.ts
Normal file
93
src/libs/Schema/Items/SchemaArray.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { ValidationError, ValidationResult } from '..'
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaArray<A> extends SchemaItem<Array<A>> {
|
||||
|
||||
public constructor(
|
||||
private readonly values: SchemaItem<A>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public override parse(input: unknown): A[] | unknown {
|
||||
// let master handle the first pass is desired
|
||||
input = super.parse(input)
|
||||
|
||||
if (!Array.isArray(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
const clone = []
|
||||
for (const item of input) {
|
||||
clone.push(this.values.parse(item))
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
public override validate(input: A[], fast = false): ValidationResult<A[]> {
|
||||
const tmp = super.validate(input, fast)
|
||||
if (tmp.error) {
|
||||
return tmp
|
||||
}
|
||||
const clone: Array<A> = []
|
||||
const errs: Array<ValidationError> = []
|
||||
for (let idx = 0; idx < tmp.object.length; idx++) {
|
||||
const item = tmp.object[idx];
|
||||
const res = this.values.validate(item as A)
|
||||
if (res.error) {
|
||||
const errors = res.error.map((it) => ({
|
||||
message: it.message,
|
||||
field: it.field ? `${idx}.${it.field}` : idx.toString()
|
||||
}))
|
||||
if (fast) {
|
||||
return {
|
||||
error: errors
|
||||
}
|
||||
}
|
||||
errs.push(...errors)
|
||||
} else {
|
||||
clone.push(res.object as A)
|
||||
}
|
||||
}
|
||||
|
||||
if (errs.length > 0) {
|
||||
return {
|
||||
error: errs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
object: clone
|
||||
}
|
||||
}
|
||||
|
||||
public override transform(input: A[]): A[] {
|
||||
const clone = []
|
||||
for (const item of super.transform(input)) {
|
||||
clone.push(this.values.transform(item))
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
/**
|
||||
* transform the array so it only contains one of each elements
|
||||
*/
|
||||
public unique(): this {
|
||||
this.transforms.push((input) => input.filter((it, idx) => input.indexOf(it) === idx))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is Array<A> {
|
||||
return Array.isArray(input)
|
||||
}
|
||||
|
||||
// public override toJSON(): JSONSchemaItem {
|
||||
// return {
|
||||
// type: 'array',
|
||||
// childs: this.values
|
||||
// }
|
||||
// }
|
||||
}
|
8
src/libs/Schema/Items/SchemaBoolean.ts
Normal file
8
src/libs/Schema/Items/SchemaBoolean.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaBoolean extends SchemaItem<boolean> {
|
||||
|
||||
public override isOfType(input: unknown): input is boolean {
|
||||
return typeof input === 'boolean'
|
||||
}
|
||||
}
|
62
src/libs/Schema/Items/SchemaDate.ts
Normal file
62
src/libs/Schema/Items/SchemaDate.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaDate extends SchemaItem<Date> {
|
||||
|
||||
public parseString(): this {
|
||||
this.parseActions.push((input) => typeof input === 'string' ? new Date(input) : input)
|
||||
return this
|
||||
}
|
||||
|
||||
public parseFrenchDate(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (typeof input !== 'string') {
|
||||
return input
|
||||
}
|
||||
const splitted = input.split('/').map((it) => Number.parseInt(it, 10))
|
||||
if (splitted.length !== 3) {
|
||||
return input
|
||||
}
|
||||
return new Date(Date.UTC(splitted[2]!, splitted[1], splitted[0]))
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public min(value: Date, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input >= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public parseFromExcelString(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (typeof input !== 'string') {
|
||||
return input
|
||||
}
|
||||
const days = parseFloat(input)
|
||||
const millis = days * 24 * 60 * 60 * 1000
|
||||
const date = new Date('1900-01-01')
|
||||
date.setTime(date.getTime() + millis)
|
||||
return date
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public max(value: Date, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input <= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is Date {
|
||||
return input instanceof Date && !isNaN(input.getTime())
|
||||
}
|
||||
}
|
21
src/libs/Schema/Items/SchemaFile.ts
Normal file
21
src/libs/Schema/Items/SchemaFile.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaFile extends SchemaItem<File> {
|
||||
constructor () {
|
||||
super()
|
||||
this.parseActions.push((input) => this.isOfType(input) && input.size > 0 ? input : undefined)
|
||||
}
|
||||
|
||||
public extension(ext: string, message?: string): this {
|
||||
this.validations.push({
|
||||
fn: (input) => input.name.endsWith(ext),
|
||||
message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is File {
|
||||
return input instanceof File
|
||||
}
|
||||
}
|
65
src/libs/Schema/Items/SchemaNullable.ts
Normal file
65
src/libs/Schema/Items/SchemaNullable.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { ValidationResult } from '..'
|
||||
import SchemaItem from '../SchemaItem'
|
||||
import { isNull } from '../utils'
|
||||
|
||||
export default class SchemaNullable<A> extends SchemaItem<A | undefined | null> {
|
||||
|
||||
public constructor(private readonly item: SchemaItem<A>) {
|
||||
super()
|
||||
}
|
||||
|
||||
public emptyAsNull(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (typeof input === 'string' && input === '') {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public falthyAsNull(): this {
|
||||
this.parseActions.push((input) => {
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override transform(input: A | null | undefined): A | null | undefined {
|
||||
const transformed = super.transform(input)
|
||||
|
||||
if (isNull(transformed) || isNull(input)) {
|
||||
return transformed
|
||||
}
|
||||
|
||||
return this.item.transform(input)
|
||||
}
|
||||
|
||||
public override validate(input: A | null | undefined): ValidationResult<A | null | undefined> {
|
||||
if (isNull(input)) {
|
||||
return {
|
||||
object: input
|
||||
}
|
||||
}
|
||||
return this.item.validate(input)
|
||||
}
|
||||
|
||||
public override parse(input: unknown): (A | null | undefined) | unknown {
|
||||
const parsed = super.parse(input)
|
||||
|
||||
if (isNull(parsed) || isNull(input)) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return this.item.parse(input)
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is A | undefined | null {
|
||||
return isNull(input) || this.item.isOfType(input)
|
||||
}
|
||||
}
|
89
src/libs/Schema/Items/SchemaNumber.ts
Normal file
89
src/libs/Schema/Items/SchemaNumber.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaNumber extends SchemaItem<number> {
|
||||
|
||||
public min(...params: Parameters<SchemaNumber['gte']>): this {
|
||||
return this.gte(...params)
|
||||
}
|
||||
|
||||
public max(...params: Parameters<SchemaNumber['lte']>): this {
|
||||
return this.lte(...params)
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is less or equal than {@link value}
|
||||
* @param value the maxumum value (inclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public lte(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input <= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is more or equal than {@link value}
|
||||
* @param value the minimum value (inclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public gte(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input >= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is less than {@link value}
|
||||
* @param value the maxumum value (exclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public lt(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input < value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the number is more than {@link value}
|
||||
* @param value the minimum value (exclusive)
|
||||
* @param message the message sent if not valid
|
||||
*/
|
||||
public gt(value: number, message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input > value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse strings before validating
|
||||
*/
|
||||
public parseString(): this {
|
||||
this.parseActions.push((input) =>
|
||||
typeof input === 'string' ? Number.parseFloat(input) : input
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is number {
|
||||
return typeof input === 'number' && !Number.isNaN(input)
|
||||
}
|
||||
}
|
88
src/libs/Schema/Items/SchemaRecord.ts
Normal file
88
src/libs/Schema/Items/SchemaRecord.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { isObject, objectLoop, objectRemap } from '@dzeio/object-util'
|
||||
import type { ValidationError, ValidationResult } from '..'
|
||||
import SchemaItem from '../SchemaItem'
|
||||
|
||||
export default class SchemaRecord<A extends string | number | symbol, B> extends SchemaItem<Record<A, B>> {
|
||||
|
||||
public constructor(
|
||||
private readonly key: SchemaItem<A>,
|
||||
private readonly values: SchemaItem<B>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public override parse(input: unknown): unknown {
|
||||
input = super.parse(input)
|
||||
|
||||
if (!this.isOfType(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
const finalObj: Record<A, B> = {} as Record<A, B>
|
||||
const error = objectLoop(input, (value, key) => {
|
||||
const res1 = this.key.parse(key)
|
||||
const res2 = this.values.parse(value)
|
||||
if (typeof res1 !== 'string' && typeof res1 !== 'number') {
|
||||
return false
|
||||
}
|
||||
// @ts-expect-error normal behavior
|
||||
finalObj[res1] = res2
|
||||
return true
|
||||
})
|
||||
if (error) {
|
||||
return input
|
||||
}
|
||||
return finalObj
|
||||
}
|
||||
|
||||
public override transform(input: Record<A, B>): Record<A, B> {
|
||||
return objectRemap(super.transform(input), (value, key) => {
|
||||
return {
|
||||
key: this.key.transform(key),
|
||||
value: this.values.transform(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public override validate(input: Record<A, B>, fast = false): ValidationResult<Record<A, B>> {
|
||||
const tmp = super.validate(input)
|
||||
if (tmp.error) {
|
||||
return tmp
|
||||
}
|
||||
|
||||
const errs: Array<ValidationError> = []
|
||||
const finalObj: Record<A, B> = {} as Record<A, B>
|
||||
|
||||
objectLoop(tmp.object, (value, key) => {
|
||||
const res1 = this.key.validate(key)
|
||||
const res2 = this.values.validate(value)
|
||||
const localErrs = (res1.error ?? []).concat(...(res2.error ?? []))
|
||||
if (localErrs.length > 0) {
|
||||
errs.push(...localErrs.map((it) => ({
|
||||
message: it.message,
|
||||
field: it.field ? `${key as string}.${it.field}` : key.toString()
|
||||
})))
|
||||
return !fast
|
||||
} else {
|
||||
// @ts-expect-error the check in the if assure the typing below
|
||||
finalObj[res1.object] = res2.object
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (errs.length > 0) {
|
||||
return {
|
||||
error: errs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
object: finalObj
|
||||
}
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is Record<A, B> {
|
||||
return isObject(input) && Object.prototype.toString.call(input) === '[object Object]'
|
||||
}
|
||||
}
|
76
src/libs/Schema/Items/SchemaString.ts
Normal file
76
src/libs/Schema/Items/SchemaString.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import SchemaItem from '../SchemaItem'
|
||||
import SchemaNullable from './SchemaNullable'
|
||||
|
||||
export default class SchemaString extends SchemaItem<string> {
|
||||
/**
|
||||
* force the input text to be a minimum of `value` size
|
||||
* @param value the minimum length of the text
|
||||
* @param message the message to display on an error
|
||||
*/
|
||||
public min(value: number, message?: string): SchemaString {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input.length >= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* force the input text to be a maximum of `value` size
|
||||
* @param value the maximum length of the text
|
||||
* @param message the message to display on an error
|
||||
*/
|
||||
public max(value: number, message?: string): SchemaString {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input.length <= value
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* the value must not be empty (`''`)
|
||||
* @param message
|
||||
* @returns
|
||||
*/
|
||||
public notEmpty(message?: string): this {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return input !== ''
|
||||
},
|
||||
message: message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* note: this nullable MUST be used last as it change the type of the returned function
|
||||
*/
|
||||
public nullable() {
|
||||
return new SchemaNullable(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* force the input text to respect a Regexp
|
||||
* @param regex the regex to validate against
|
||||
* @param message the message to display on an error
|
||||
*/
|
||||
public regex(regex: RegExp, message?: string): SchemaString {
|
||||
this.validations.push({
|
||||
fn(input) {
|
||||
return regex.test(input)
|
||||
},
|
||||
message
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is string {
|
||||
return typeof input === 'string'
|
||||
}
|
||||
}
|
25
src/libs/Schema/README.md
Normal file
25
src/libs/Schema/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
a Full featured and lightweight Schema validation/parsing library
|
||||
|
||||
it is meant to be used for input validation
|
||||
|
||||
example :
|
||||
|
||||
```ts
|
||||
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||
|
||||
const schema = new Schema({
|
||||
test: s.record(s.string(), s.object({
|
||||
a: s.number().parseString().min(3, 'a is too small')
|
||||
}))
|
||||
})
|
||||
|
||||
const t = {
|
||||
test: {
|
||||
b: {a: '34'}
|
||||
}
|
||||
}
|
||||
|
||||
// validate that `t` is coherant with the schema above
|
||||
const { object, error } = schema.validate(t)
|
||||
console.log(object, error)
|
||||
```
|
169
src/libs/Schema/SchemaItem.ts
Normal file
169
src/libs/Schema/SchemaItem.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import type { ValidationResult } from '.'
|
||||
import Schema from '.'
|
||||
import { isNull } from './utils'
|
||||
|
||||
export interface Messages {
|
||||
globalInvalid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* An element of a schema
|
||||
*/
|
||||
export default abstract class SchemaItem<T> {
|
||||
/**
|
||||
* get additionnal attributes used to make the Schema work with outside libs
|
||||
*/
|
||||
public attributes: Array<string> = []
|
||||
|
||||
/**
|
||||
* the list of validations
|
||||
*/
|
||||
protected validations: Array<{
|
||||
fn: (input: T) => boolean
|
||||
message?: string | undefined
|
||||
}> = []
|
||||
|
||||
protected parseActions: Array<(input: unknown) => T | unknown> = []
|
||||
protected transforms: Array<(input: T) => T> = []
|
||||
|
||||
/**
|
||||
* set the list of attributes for the item of the schema
|
||||
* @param attributes the attributes
|
||||
*/
|
||||
public attr(...attributes: Array<string>) {
|
||||
this.attributes = attributes
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* set the default value of the schema element
|
||||
* @param value the default value
|
||||
* @param strict if strict, it will use it for null/undefined, else it will check for falthy values
|
||||
*/
|
||||
public defaultValue(value: T, strict = true) {
|
||||
this.parseActions.push((input) => {
|
||||
if (strict && isNull(input)) {
|
||||
return value
|
||||
}
|
||||
if (!value) {
|
||||
return input
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param values the possible values the field can be
|
||||
* @param message the message returned if it does not respect the value
|
||||
*/
|
||||
public in(values: Array<T>, message?: string) {
|
||||
this.validations.push({
|
||||
fn: (input) => values.includes(input),
|
||||
message
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse the input from another format
|
||||
*
|
||||
* @param input the input to transform, it is done before validation, so the value can be anything
|
||||
* @returns the transformed value
|
||||
*/
|
||||
public parse(input: unknown): T | unknown {
|
||||
for (const transform of this.parseActions) {
|
||||
const tmp = transform(input)
|
||||
if (this.isOfType(tmp)) {
|
||||
return tmp
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* transform a valid value
|
||||
*
|
||||
* @param input the input to transform, it MUST be validated beforehand
|
||||
* @returns the transformed value
|
||||
*/
|
||||
public transform(input: T): T {
|
||||
let res = input
|
||||
|
||||
for (const action of this.transforms) {
|
||||
res = action(res)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the input is valid or not
|
||||
* @param input the input to validate
|
||||
* @param fast if true the validation stops at the first error
|
||||
* @returns a string if it's not valid, else null
|
||||
*/
|
||||
public validate(input: T, fast = false): ValidationResult<T> {
|
||||
for (const validation of this.validations) {
|
||||
if (!validation.fn(input as T)) {
|
||||
return {
|
||||
error: [{
|
||||
message: validation.message ?? Schema.messages.globalInvalid
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
object: input as T
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the input value is of the type of the schema item
|
||||
*
|
||||
* it makes others functions easier to works with
|
||||
* @param input the input to validate
|
||||
*/
|
||||
public abstract isOfType(input: unknown): input is T
|
||||
|
||||
// public abstract toJSON(): JSONSchemaItem
|
||||
}
|
||||
|
||||
type Parseable = string | number | boolean
|
||||
|
||||
export interface ValidatorJSON {
|
||||
/**
|
||||
* the function name (ex: `min`, `max`)
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* the function parameters
|
||||
*/
|
||||
params?: Array<Parseable>
|
||||
}
|
||||
|
||||
export interface JSONSchemaItem {
|
||||
/**
|
||||
* Schema item
|
||||
*
|
||||
* ex: `string`, `number`, `boolean`, ...
|
||||
*/
|
||||
type: string
|
||||
/**
|
||||
* constructor params
|
||||
*/
|
||||
params?: Array<Parseable>
|
||||
/**
|
||||
* list of attributes
|
||||
*/
|
||||
attributes?: Array<string>
|
||||
actions?: Array<ValidatorJSON>
|
||||
}
|
||||
|
||||
export type JSONSchema = {
|
||||
[a: string]: JSONSchemaItem
|
||||
}
|
216
src/libs/Schema/index.ts
Normal file
216
src/libs/Schema/index.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { isObject, objectLoop } from '@dzeio/object-util'
|
||||
import DzeioLiteral from './Items/DzeioLiteral'
|
||||
import SchemaArray from './Items/SchemaArray'
|
||||
import SchemaBoolean from './Items/SchemaBoolean'
|
||||
import SchemaDate from './Items/SchemaDate'
|
||||
import SchemaFile from './Items/SchemaFile'
|
||||
import SchemaNullable from './Items/SchemaNullable'
|
||||
import SchemaNumber from './Items/SchemaNumber'
|
||||
import SchemaRecord from './Items/SchemaRecord'
|
||||
import SchemaString from './Items/SchemaString'
|
||||
import SchemaItem from './SchemaItem'
|
||||
|
||||
export interface ValidationError {
|
||||
message: string
|
||||
field?: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type ValidationResult<T> = {
|
||||
object: T
|
||||
error?: undefined
|
||||
} | {
|
||||
object?: undefined
|
||||
error: Array<ValidationError>
|
||||
}
|
||||
|
||||
export type Model = Record<string, SchemaItem<any>>
|
||||
|
||||
export type SchemaInfer<S extends Schema> = ModelInfer<S['model']>
|
||||
|
||||
export type ModelInfer<M extends Model> = {
|
||||
[key in keyof M]: ReturnType<M[key]['transform']>
|
||||
}
|
||||
|
||||
/**
|
||||
* A schema to validate input or external datas
|
||||
*/
|
||||
export default class Schema<M extends Model = Model> extends SchemaItem<ModelInfer<Model>> {
|
||||
|
||||
public static messages = {
|
||||
typeInvalid: 'Type of field is not valid',
|
||||
notAnObject: 'the data submitted is not valid',
|
||||
globalInvalid: 'the field is invalid'
|
||||
}
|
||||
|
||||
public constructor(public readonly model: M) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* See {@link SchemaString}
|
||||
*/
|
||||
public static string(
|
||||
...inputs: ConstructorParameters<typeof SchemaString>
|
||||
): SchemaString {
|
||||
return new SchemaString(...inputs)
|
||||
}
|
||||
|
||||
public static file(
|
||||
...inputs: ConstructorParameters<typeof SchemaFile>
|
||||
): SchemaFile {
|
||||
return new SchemaFile(...inputs)
|
||||
}
|
||||
|
||||
public static number(
|
||||
...inputs: ConstructorParameters<typeof SchemaNumber>
|
||||
): SchemaNumber {
|
||||
return new SchemaNumber(...inputs)
|
||||
}
|
||||
|
||||
public static date(
|
||||
...inputs: ConstructorParameters<typeof SchemaDate>
|
||||
): SchemaDate {
|
||||
return new SchemaDate(...inputs)
|
||||
}
|
||||
|
||||
public static literal<T>(
|
||||
...inputs: ConstructorParameters<typeof DzeioLiteral<T>>
|
||||
): DzeioLiteral<T> {
|
||||
return new DzeioLiteral<T>(...inputs)
|
||||
}
|
||||
|
||||
public static object<T extends Model>(
|
||||
...inputs: ConstructorParameters<typeof Schema<T>>
|
||||
): Schema<T> {
|
||||
return new Schema(...inputs)
|
||||
}
|
||||
|
||||
public static record<A extends string | number, B>(
|
||||
...inputs: ConstructorParameters<typeof SchemaRecord<A, B>>
|
||||
): SchemaRecord<A, B> {
|
||||
return new SchemaRecord<A, B>(...inputs)
|
||||
}
|
||||
|
||||
public static array<A>(
|
||||
...inputs: ConstructorParameters<typeof SchemaArray<A>>
|
||||
): SchemaArray<A> {
|
||||
return new SchemaArray<A>(...inputs)
|
||||
}
|
||||
|
||||
public static nullable<A>(
|
||||
...inputs: ConstructorParameters<typeof SchemaNullable<A>>
|
||||
): SchemaNullable<A> {
|
||||
return new SchemaNullable<A>(...inputs)
|
||||
}
|
||||
|
||||
public static boolean(
|
||||
...inputs: ConstructorParameters<typeof SchemaBoolean>
|
||||
): SchemaBoolean {
|
||||
return new SchemaBoolean(...inputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param query the URL params to validate
|
||||
* @returns
|
||||
*/
|
||||
public validateQuery(query: URLSearchParams, fast = false): ReturnType<Schema<M>['validate']> {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (const [key, value] of query) {
|
||||
record[key] = value
|
||||
}
|
||||
|
||||
return this.validate(record, fast)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param form the form to validate
|
||||
*/
|
||||
public validateForm(form: HTMLFormElement, fast = false): ReturnType<Schema<M>['validate']> {
|
||||
const data = new FormData(form)
|
||||
return this.validateFormData(data, fast)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param data the FormData to validate
|
||||
* @returns
|
||||
*/
|
||||
public validateFormData(data: FormData, fast = false): ReturnType<Schema<M>['validate']> {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (const [key, value] of data) {
|
||||
const isArray = this.model[key]?.isOfType([]) ?? false
|
||||
record[key] = isArray ? data.getAll(key) : value
|
||||
}
|
||||
|
||||
return this.validate(record, fast)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param input the data to validate
|
||||
* @param options additionnal validation options
|
||||
* @returns blablabla
|
||||
*/
|
||||
public override validate(input: unknown, fast = false): ValidationResult<SchemaInfer<this>> {
|
||||
if (!isObject(input)) {
|
||||
return {
|
||||
error: [{
|
||||
message: Schema.messages.notAnObject
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const errors: Array<ValidationError> = []
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
const res: ModelInfer<M> = {} as any
|
||||
objectLoop(this.model, (v, k) => {
|
||||
// parse value from other formats
|
||||
const value = v.parse(input[k])
|
||||
|
||||
// validate that the value is of type
|
||||
if (!v.isOfType(value)) {
|
||||
errors.push({
|
||||
message: Schema.messages.typeInvalid,
|
||||
field: k,
|
||||
value: value
|
||||
})
|
||||
return !fast
|
||||
}
|
||||
|
||||
// run validations
|
||||
const invalid = v.validate(value)
|
||||
if (invalid.error) {
|
||||
errors.push(...invalid.error.map((it) => ({
|
||||
message: it.message,
|
||||
field: it.field ? `${k}.${it.field}` : k
|
||||
})))
|
||||
return !fast
|
||||
}
|
||||
|
||||
// transform and assign final value
|
||||
// @ts-expect-error normal behavior
|
||||
res[k] = v.transform(value)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
error: errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
object: res
|
||||
}
|
||||
}
|
||||
|
||||
public override isOfType(input: unknown): input is ModelInfer<Model> {
|
||||
return isObject(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* alias of {@link Schema}
|
||||
*/
|
||||
export const s = Schema
|
3
src/libs/Schema/utils.ts
Normal file
3
src/libs/Schema/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isNull(value: unknown): value is undefined | null {
|
||||
return typeof value === 'undefined' || value === null
|
||||
}
|
35
src/middleware/database.ts
Normal file
35
src/middleware/database.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { defineMiddleware } from 'astro/middleware'
|
||||
import { buildRFC7807 } from 'libs/RFCs/RFC7807'
|
||||
import config from 'models/config'
|
||||
import route from 'route'
|
||||
|
||||
// `context` and `next` are automatically typed
|
||||
export default defineMiddleware(async (ctx, next) => {
|
||||
const url = ctx.url
|
||||
const client = config.mainClient
|
||||
const c = await client.get()
|
||||
await c.connect()
|
||||
const isMigrated = await c.isMigrated()
|
||||
|
||||
if (!isMigrated) {
|
||||
c.migrateToLatest()
|
||||
}
|
||||
// check if the client is ready
|
||||
if (!isMigrated && url.pathname !== '/setup') {
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
// don't redirect but indicate it usign a RFC7807
|
||||
return buildRFC7807({
|
||||
title: 'Server is starting up',
|
||||
status: 503,
|
||||
details: 'Server is starting, please wait a bit'
|
||||
})
|
||||
}
|
||||
// redirect user to a specific page
|
||||
return ctx.redirect(route('/setup', { goto: url.pathname + url.search }))
|
||||
}
|
||||
|
||||
if (isMigrated && url.pathname === '/setup') {
|
||||
return ctx.redirect(url.searchParams.get('goto') ?? route('/'))
|
||||
}
|
||||
return next()
|
||||
})
|
@ -1,5 +1,6 @@
|
||||
import { sequence } from "astro/middleware"
|
||||
|
||||
import database from "./database"
|
||||
import logger from './logger'
|
||||
|
||||
export const onRequest = sequence(logger)
|
||||
export const onRequest = sequence(logger, database)
|
||||
|
@ -3,7 +3,8 @@ 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> {
|
||||
// eslint-disable-next-line complexity
|
||||
export function filter<T extends object>(query: Query<T>, results: Array<T>, options?: { debug?: boolean }): {filtered: Array<T>, unpaginatedLength: number} {
|
||||
if (options?.debug) {
|
||||
console.log('Query', query)
|
||||
}
|
||||
@ -36,27 +37,54 @@ export function filter<T extends object>(query: Query<T>, results: Array<T>, opt
|
||||
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
|
||||
filtered = filtered.sort((objA, objB) => {
|
||||
const a = objA[first!.key]
|
||||
const b = objB[first!.key]
|
||||
const ascend = first?.value !== Sort.DESC
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
if (ascend) {
|
||||
return b - a
|
||||
} else {
|
||||
return a - b
|
||||
}
|
||||
}
|
||||
return (a[first!.key] ?? 0) > (b[first!.key] ?? 0) ? -1 : 1
|
||||
if (a instanceof Date && b instanceof Date) {
|
||||
if (ascend) {
|
||||
return a.getTime() - b.getTime()
|
||||
} else {
|
||||
return b.getTime() - a.getTime()
|
||||
}
|
||||
}
|
||||
if (typeof a === 'string' && typeof b === 'string') {
|
||||
if (ascend) {
|
||||
return a.localeCompare(b)
|
||||
} else {
|
||||
return b.localeCompare(a)
|
||||
}
|
||||
|
||||
}
|
||||
if (ascend) {
|
||||
return a > b ? 1 : -1
|
||||
}
|
||||
return a > b ? -1 : 1
|
||||
})
|
||||
}
|
||||
if (options?.debug) {
|
||||
console.log('postSort', filtered)
|
||||
}
|
||||
|
||||
// length of the query assuming a single page
|
||||
const unpaginatedLength = filtered.length
|
||||
// limit
|
||||
if (query.$offset || query.$limit) {
|
||||
const offset = query.$offset ?? 0
|
||||
filtered = filtered.slice(offset, offset + (query.$limit ?? 0))
|
||||
filtered = filtered.slice(offset, offset + (query.$limit ?? Infinity))
|
||||
}
|
||||
if (options?.debug) {
|
||||
console.log('postLimit', filtered)
|
||||
}
|
||||
|
||||
return filtered
|
||||
return { filtered, unpaginatedLength }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,7 +170,7 @@ function filterItem(value: any, query: QueryValues<AllowedValues>): boolean {
|
||||
}
|
||||
|
||||
if ('$inc' in query) {
|
||||
return value.toString().includes(query.$inc)
|
||||
return (value.toString() as string).toLowerCase().includes(query.$inc!.toString()!.toLowerCase())
|
||||
}
|
||||
|
||||
if ('$eq' in query) {
|
||||
@ -176,6 +204,9 @@ function filterItem(value: any, query: QueryValues<AllowedValues>): boolean {
|
||||
return typeof value === 'number' && typeof comparedValue === 'number' && value <= comparedValue
|
||||
}
|
||||
|
||||
if ('$len' in query && Array.isArray(value)) {
|
||||
return value.length === query.$len
|
||||
}
|
||||
|
||||
/**
|
||||
* Logical Operators
|
||||
|
55
src/models/Adapters/CSVAdapter.ts
Normal file
55
src/models/Adapters/CSVAdapter.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { CSVOptions } from 'libs/FilesFormats/CSV'
|
||||
import CSV from 'libs/FilesFormats/CSV'
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { SchemaInfer } from 'libs/Schema'
|
||||
import fs from 'node:fs'
|
||||
import type { Query } from '../Query'
|
||||
import { filter } from './AdapterUtils'
|
||||
import type DaoAdapter from './DaoAdapter'
|
||||
import type { DBPull } from './DaoAdapter'
|
||||
|
||||
|
||||
export default class CSVAdapter<T extends Schema> implements DaoAdapter<T> {
|
||||
|
||||
private data: Array<SchemaInfer<T>>
|
||||
|
||||
public constructor(
|
||||
public readonly schema: T,
|
||||
public readonly serverPath: string,
|
||||
private readonly csvOptions?: CSVOptions
|
||||
) {
|
||||
const data = fs.readFileSync(serverPath, 'utf-8')
|
||||
this.data = CSV.parse(data, csvOptions) as Array<SchemaInfer<T>>
|
||||
}
|
||||
|
||||
public async create(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
|
||||
public async read(query?: Query<SchemaInfer<T>>): Promise<DBPull<T>> {
|
||||
|
||||
const res = filter(query ?? {}, this.data)
|
||||
|
||||
return {
|
||||
rows: res.filtered.length,
|
||||
rowsTotal: res.unpaginatedLength,
|
||||
page: 1,
|
||||
pageTotal: 1,
|
||||
data: res.filtered
|
||||
}
|
||||
}
|
||||
|
||||
public async update(_obj: SchemaInfer<T>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
public async patch(_id: string, _obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
|
||||
}
|
||||
|
||||
public async delete(obj: SchemaInfer<T>): Promise<boolean> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
/* 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 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 Client from '../Clients/CassandraClient'
|
||||
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'
|
||||
import type DaoAdapter from './DaoAdapter'
|
||||
import type { DBPull } from './DaoAdapter'
|
||||
|
||||
export default class CassandraAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
|
||||
@ -35,12 +35,12 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
/**
|
||||
* additionnal options to make the adapter work
|
||||
*/
|
||||
private readonly options?: {
|
||||
public readonly options: {
|
||||
/**
|
||||
* log the requests made to cassandra
|
||||
*/
|
||||
debug?: boolean
|
||||
}
|
||||
} = {}
|
||||
) {
|
||||
if (!id) {
|
||||
objectLoop(schema.model, (value, key) => {
|
||||
@ -174,7 +174,7 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
console.log(req, params)
|
||||
}
|
||||
|
||||
let res: types.ResultSet | undefined
|
||||
let res: Array<Record<string, any>>
|
||||
try {
|
||||
res = await client.execute(req.join(' '), params)
|
||||
} catch (error) {
|
||||
@ -192,10 +192,10 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
}
|
||||
}
|
||||
|
||||
let dataset = res.rows
|
||||
let dataset = res
|
||||
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
|
||||
key,
|
||||
value: this.dbToValue(key, obj.get(key))
|
||||
value: this.dbToValue(key, obj[key])
|
||||
})))
|
||||
.map((obj) => {
|
||||
objectLoop(this.schema.model, (item, key) => {
|
||||
@ -248,6 +248,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
// dataset = dataset.slice(page * (query?.limit ?? 0), limit)
|
||||
// }
|
||||
|
||||
// length of the query assuming a single page
|
||||
let unpaginatedLength = dataset.length
|
||||
// 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
|
||||
@ -259,17 +261,19 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
dataset = filter(query, dataset, this.options)
|
||||
const { filtered, unpaginatedLength: ul } = filter(query, dataset, this.options)
|
||||
dataset = filtered
|
||||
unpaginatedLength = ul
|
||||
}
|
||||
|
||||
// console.log(res)
|
||||
const pageLimit = query?.$limit ?? 10
|
||||
const pageOffset = query?.$offset ?? 0
|
||||
return {
|
||||
rows: res.rows.length,
|
||||
rowsTotal: res.rowLength,
|
||||
page: 1,
|
||||
pageTotal: 1,
|
||||
// page: page,
|
||||
// pageTotal: pageLimit ? res.rowLength / pageLimit : 1,
|
||||
rows: dataset.length,
|
||||
rowsTotal: unpaginatedLength,
|
||||
page: Math.floor(pageOffset / pageLimit),
|
||||
pageTotal: Math.max(1, Math.ceil(unpaginatedLength / pageLimit)),
|
||||
data: dataset
|
||||
}
|
||||
}
|
||||
@ -303,7 +307,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
const params: Array<any> = []
|
||||
|
||||
// remove ids
|
||||
for (const tmp of this.id) {
|
||||
const ids = Array.isArray(this.id) ? this.id : [this.id]
|
||||
for (const tmp of ids) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete obj[tmp]
|
||||
}
|
||||
@ -316,8 +321,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
// 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
|
||||
for (let idx = 0; idx < ids.length; idx++) {
|
||||
const key = ids[idx] as string
|
||||
|
||||
if (idx > 0) {
|
||||
parts.push('AND')
|
||||
@ -364,8 +369,15 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
||||
const parts = ['DELETE', 'FROM', this.table, 'WHERE']
|
||||
const params: ArrayOrObject = []
|
||||
|
||||
objectLoop(obj as {}, (value, key, idx) => {
|
||||
if (idx > 0) {
|
||||
objectLoop(obj as {}, (value, key) => {
|
||||
let allowedWheres = ([] as Array<any>).concat(Array.isArray(this.id) ? this.id : [this.id])
|
||||
if (this.partitionKeys) {
|
||||
allowedWheres.push(...this.partitionKeys )
|
||||
}
|
||||
if (!allowedWheres.includes(key)) {
|
||||
return
|
||||
}
|
||||
if (parts.length > 4) {
|
||||
parts.push('AND')
|
||||
}
|
||||
parts.push(`${key}=?`)
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { type Query } from './Query'
|
||||
import type { Implementation, Model } from './Schema'
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { SchemaInfer } from 'libs/Schema'
|
||||
import type { Query } from '../Query'
|
||||
|
||||
export interface DBPull<T extends Model> {
|
||||
export interface DBPull<T extends Schema> {
|
||||
/**
|
||||
* total number of rows that are valid with the specified query
|
||||
*/
|
||||
@ -24,7 +25,7 @@ export interface DBPull<T extends Model> {
|
||||
/**
|
||||
* the data fetched
|
||||
*/
|
||||
data: Array<Implementation<T>>
|
||||
data: Array<SchemaInfer<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,27 +33,27 @@ export interface DBPull<T extends Model> {
|
||||
*
|
||||
* you MUST call it through the `DaoFactory` file
|
||||
*/
|
||||
export default interface DaoAdapter<T extends Model> {
|
||||
export default interface DaoAdapter<T extends Schema> {
|
||||
/**
|
||||
* create a new object in the remote source
|
||||
*
|
||||
* @param obj the object to create
|
||||
*/
|
||||
create?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||
create?(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<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>>
|
||||
read?(query?: Query<SchemaInfer<T>>): 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>
|
||||
update?(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
|
||||
|
||||
/**
|
||||
* depending if the object already exists or not
|
||||
@ -60,7 +61,7 @@ export default interface DaoAdapter<T extends Model> {
|
||||
*
|
||||
* @param obj the object to insert/update
|
||||
*/
|
||||
upsert?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
|
||||
upsert?(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
|
||||
|
||||
/**
|
||||
* update an object to the remote source
|
||||
@ -68,11 +69,11 @@ export default interface DaoAdapter<T extends Model> {
|
||||
* @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>
|
||||
patch?(id: string, obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<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>
|
||||
delete?(obj: Partial<SchemaInfer<T>>): Promise<boolean>
|
||||
}
|
@ -1,26 +1,26 @@
|
||||
/* 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'
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { Model, ModelInfer } from 'libs/Schema'
|
||||
import type SchemaBuffer from 'libs/Schema/Items/SchemaBuffer'
|
||||
import type SchemaNumber from 'libs/Schema/Items/SchemaNumber'
|
||||
import type SchemaString from 'libs/Schema/Items/SchemaString'
|
||||
import fileSystem from 'node:fs'
|
||||
import fs from 'node:fs/promises'
|
||||
import type { Query } from '../Query'
|
||||
import type DaoAdapter from './DaoAdapter'
|
||||
import type { DBPull } from './DaoAdapter'
|
||||
|
||||
interface FS extends Model {
|
||||
filename: StringConstructor
|
||||
path: StringConstructor
|
||||
filename: SchemaString
|
||||
path: SchemaString
|
||||
// eslint-disable-next-line no-undef
|
||||
data: BufferConstructor
|
||||
type: StringConstructor
|
||||
size: NumberConstructor
|
||||
data: SchemaBuffer
|
||||
type: SchemaString
|
||||
size: SchemaNumber
|
||||
}
|
||||
|
||||
export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
|
||||
private id!: string
|
||||
export default class FSAdapter<T extends FS> implements DaoAdapter<Schema<T>> {
|
||||
|
||||
public constructor(
|
||||
public readonly schema: Schema<T>,
|
||||
@ -33,7 +33,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
}
|
||||
|
||||
// TODO: make it clearer what it does
|
||||
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async create(obj: Partial<ModelInfer<T>>): Promise<ModelInfer<T> | null> {
|
||||
const realPath = this.getFullPath(obj.path!)
|
||||
|
||||
const finalFolder = realPath.slice(0, realPath.lastIndexOf('/'))
|
||||
@ -51,49 +51,16 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
} else {
|
||||
await fs.writeFile(realPath, data as string)
|
||||
}
|
||||
return obj as Implementation<T>
|
||||
return obj as ModelInfer<T>
|
||||
} else {
|
||||
console.log('making the final directory', realPath)
|
||||
await fs.mkdir(realPath)
|
||||
return obj as Implementation<T>
|
||||
return obj as ModelInfer<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>> {
|
||||
public async read(query?: Query<ModelInfer<T>> | undefined): Promise<DBPull<Schema<T>>> {
|
||||
|
||||
const localPath = query?.path as string ?? ''
|
||||
|
||||
@ -104,38 +71,24 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
try {
|
||||
const stats = await fs.stat(realPath)
|
||||
|
||||
const files: Array<Implementation<T>> = []
|
||||
const files: Array<ModelInfer<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))
|
||||
}
|
||||
for await (const file of dirFiles) {
|
||||
files.push(await this.readFile(`${localPath}/${file}`))
|
||||
}
|
||||
} else {
|
||||
files.push(await this.readFile(localPath))
|
||||
}
|
||||
|
||||
const pageLimit = query?.$limit ?? Infinity
|
||||
const pageOffset = query?.$offset ?? 0
|
||||
return {
|
||||
rows: files.length,
|
||||
rowsTotal: files.length,
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
data: files
|
||||
page: Math.floor(pageOffset / pageLimit),
|
||||
pageTotal: Math.max(1, Math.ceil(files.length / pageLimit)),
|
||||
data: files.slice(pageOffset, pageOffset + pageLimit)
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
@ -148,23 +101,32 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
}
|
||||
}
|
||||
|
||||
public async update(_obj: Implementation<T>): Promise<Implementation<T> | null> {
|
||||
public async update(_obj: ModelInfer<T>): Promise<ModelInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async patch(_id: string, _obj: Partial<ModelInfer<T>>): Promise<ModelInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
|
||||
}
|
||||
|
||||
public async delete(_obj: Implementation<T>): Promise<boolean> {
|
||||
throw new Error('not implemented')
|
||||
public async delete(obj: ModelInfer<T>): Promise<boolean> {
|
||||
const localPath = obj?.path as string ?? ''
|
||||
const realPath = this.getFullPath(localPath)
|
||||
|
||||
try {
|
||||
await fs.stat(realPath)
|
||||
await fs.rm(realPath, { recursive: true, force: true })
|
||||
return true
|
||||
} catch {
|
||||
console.error('Could not remove file', localPath)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private getFullPath(localPath?: string): string {
|
||||
if (localPath && !localPath?.startsWith('/')) {
|
||||
console.warn('Your path should start with a "/", adding it')
|
||||
localPath = ('/' + localPath) as any
|
||||
localPath = (`/${localPath}`)
|
||||
}
|
||||
|
||||
let realPath = this.basePath + (localPath ? localPath : '')
|
||||
@ -176,7 +138,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
return realPath
|
||||
}
|
||||
|
||||
private async readFile(localPath: string): Promise<Implementation<T>> {
|
||||
private async readFile(localPath: string): Promise<ModelInfer<T>> {
|
||||
|
||||
const path = this.getFullPath(localPath)
|
||||
console.log('reading file at', path)
|
||||
@ -184,7 +146,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
const type = stats.isFile() ? 'file' : 'directory'
|
||||
console.log('file is a', type)
|
||||
|
||||
const obj: Implementation<T> = {
|
||||
const obj: ModelInfer<T> = {
|
||||
path: localPath,
|
||||
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
|
||||
data: type === 'file' ? await fs.readFile(path) : '',
|
||||
@ -193,10 +155,10 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
||||
} as any
|
||||
|
||||
objectLoop(this.schema.model, (item, key) => {
|
||||
if (isSchemaItem(item) && item.database?.created) {
|
||||
if (item.attributes.includes('db:created')) {
|
||||
// @ts-expect-error things get validated anyway
|
||||
obj[key] = stats.ctime
|
||||
} else if (isSchemaItem(item) && item.database?.updated) {
|
||||
} else if (item.attributes.includes('db:updated')) {
|
||||
// @ts-expect-error things get validated anyway
|
||||
obj[key] = stats.mtime
|
||||
}
|
||||
|
@ -1,25 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { objectClone, objectLoop, objectMap, objectRemap } from '@dzeio/object-util'
|
||||
import { objectClone, objectLoop, objectMap, objectOmit, 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'
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { SchemaInfer } from 'libs/Schema'
|
||||
import type DaoAdapter from 'models/Adapters/DaoAdapter'
|
||||
import type { DBPull } from 'models/Adapters/DaoAdapter'
|
||||
import type { Query } from 'models/Query'
|
||||
import { filter } from './AdapterUtils'
|
||||
type LDAPFields = 'uid' | 'mail' | 'givenname' | 'sn' | 'jpegphoto' | 'password'
|
||||
|
||||
export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
export default class LDAPAdapter<T extends Schema> 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 schema: T,
|
||||
public readonly options: {
|
||||
url: string
|
||||
dnSuffix: string
|
||||
adminUsername: string
|
||||
adminPassword: string
|
||||
fieldsCorrespondance?: Partial<Record<keyof T, LDAPFields | string>>
|
||||
admin: {
|
||||
dn?: string | undefined
|
||||
username?: string | undefined
|
||||
password: string
|
||||
}
|
||||
fieldsCorrespondance?: Partial<Record<keyof SchemaInfer<T>, LDAPFields | string>>
|
||||
}
|
||||
) {
|
||||
objectLoop(options.fieldsCorrespondance ?? {}, (value, key) => {
|
||||
@ -29,12 +34,12 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
|
||||
// TODO: make it clearer what it does
|
||||
public async create(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async create(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||
public async read(query?: Query<SchemaInfer<T>> | undefined): Promise<DBPull<T>> {
|
||||
const passwordField = this.options.fieldsCorrespondance?.password ?? 'password'
|
||||
const doLogin = !!query?.[passwordField]
|
||||
|
||||
@ -56,51 +61,67 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
?.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)
|
||||
const bind = this.options.admin.dn ?? `cn=${this.options.admin.username},${this.options.dnSuffix}`
|
||||
try {
|
||||
const client = await this.bind(bind, this.options.admin.password)
|
||||
// @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: this.options.fieldsCorrespondance?.[key], value: value}))
|
||||
)).map((it) => this.schema.parse(
|
||||
objectRemap(it, (value, key) => ({key: this.reverseReference[key.toLowerCase() as string] as string, value: value}))
|
||||
)).filter((it): it is SchemaInfer<T> => !!it)
|
||||
|
||||
return {
|
||||
rows: results.length,
|
||||
rowsTotal: results.length,
|
||||
page: 1,
|
||||
pageTotal: 1,
|
||||
data: results
|
||||
const res = filter(query, results)
|
||||
|
||||
return {
|
||||
rows: res.filtered.length,
|
||||
rowsTotal: results.length,
|
||||
page: 1,
|
||||
pageTotal: 1,
|
||||
data: res.filtered
|
||||
}
|
||||
} catch {
|
||||
return emptyResult
|
||||
}
|
||||
}
|
||||
|
||||
// password authentication
|
||||
try {
|
||||
const clone = objectClone(query)
|
||||
delete clone.password
|
||||
|
||||
// find using admin privileges
|
||||
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)
|
||||
const client = await this.bind(`uid=${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)
|
||||
const results = (await this.ldapFind(client, objectMap(clone, (value, key) => {
|
||||
const finalKey = this.options.fieldsCorrespondance?.[key]
|
||||
|
||||
if (results.length !== 1) {
|
||||
return {key: finalKey, 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 SchemaInfer<T> => !!it)
|
||||
|
||||
const final = filter(objectOmit(query, 'password'), results)
|
||||
// console.log(final, query, results)
|
||||
|
||||
if (final.filtered.length !== 1) {
|
||||
return emptyResult
|
||||
}
|
||||
|
||||
return {
|
||||
rows: results.length,
|
||||
rows: final.filtered.length,
|
||||
rowsTotal: results.length,
|
||||
page: 1,
|
||||
pageTotal: 1,
|
||||
data: results
|
||||
data: final.filtered
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
@ -109,15 +130,15 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
}
|
||||
|
||||
public async update(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async update(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async patch(_id: string, _obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
public async delete(_obj: Partial<Implementation<T>>): Promise<boolean> {
|
||||
public async delete(_obj: Partial<SchemaInfer<T>>): Promise<boolean> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
@ -126,11 +147,13 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
url: this.options.url
|
||||
})
|
||||
return new Promise<ldap.Client>((res, rej) => {
|
||||
console.log('binding as', dn)
|
||||
client.on('connect', () => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) {
|
||||
console.error('error binding as', dn, err)
|
||||
client.unbind()
|
||||
rej(err)
|
||||
return
|
||||
}
|
||||
console.log('binded as', dn)
|
||||
@ -152,12 +175,15 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
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>> = []
|
||||
const filter = {
|
||||
attribute: firstFilter.key as any,
|
||||
value: firstFilter.value,
|
||||
}
|
||||
console.log('Searching on LDAP')
|
||||
client.search(
|
||||
this.options.dnSuffix, {
|
||||
filter: new ldap.EqualityFilter({
|
||||
attribute: firstFilter.key as any,
|
||||
value: firstFilter.value,
|
||||
}),
|
||||
filter: new ldap.EqualityFilter(filter),
|
||||
// filter: `${filter.attribute}:caseExactMatch:=${filter.value}`,
|
||||
scope: 'sub',
|
||||
attributes: this.attributes
|
||||
}, (err, search) => {
|
||||
@ -184,10 +210,12 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
for (const attribute of usr.attributes) {
|
||||
user[attribute.type] = attribute.values.length === 1 ? attribute.values[0] : attribute.values
|
||||
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import type DaoAdapter from 'models/DaoAdapter'
|
||||
import Schema, { type Implementation, type Model } from 'models/Schema'
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { SchemaInfer } from 'libs/Schema'
|
||||
import type DaoAdapter from 'models/Adapters/DaoAdapter'
|
||||
|
||||
export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
export default class MultiAdapter<T extends Schema> implements DaoAdapter<T> {
|
||||
|
||||
public constructor(
|
||||
public readonly schema: Schema<T>,
|
||||
public readonly schema: T,
|
||||
public readonly adapters: Array<{
|
||||
adapter: DaoAdapter<Partial<T>>
|
||||
adapter: DaoAdapter<T>
|
||||
fields: Array<keyof T>
|
||||
/**
|
||||
* a field from the main adapter that will backreference the child adapter
|
||||
@ -16,11 +17,11 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<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
|
||||
public async create(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
let final: SchemaInfer<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>> = {}
|
||||
const partialObject: Partial<SchemaInfer<T>> = {}
|
||||
for (const key of adapter.fields) {
|
||||
partialObject[key] = obj[key]
|
||||
}
|
||||
@ -34,11 +35,11 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
// public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||
// let final: Implementation<T> = {} as any
|
||||
// public async read(query?: Query<SchemaInfer<T>> | undefined): Promise<DBPull<T>> {
|
||||
// let final: SchemaInfer<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>> = {}
|
||||
// const partialObject: Partial<SchemaInfer<T>> = {}
|
||||
// for (const key of adapter.fields) {
|
||||
// partialObject[key] = obj[key]
|
||||
// }
|
||||
@ -52,16 +53,16 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
// return final
|
||||
// }
|
||||
|
||||
public async update(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async update(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async patch(_id: string, _obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
throw new Error('not implemented')
|
||||
|
||||
}
|
||||
|
||||
public async delete(_obj: Partial<Implementation<T>>): Promise<boolean> {
|
||||
public async delete(_obj: Partial<SchemaInfer<T>>): Promise<boolean> {
|
||||
throw new Error('not implemented')
|
||||
|
||||
}
|
||||
|
@ -1,17 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
|
||||
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { SchemaInfer } from 'libs/Schema'
|
||||
import type SchemaItem from 'libs/Schema/SchemaItem'
|
||||
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 PostgresClient from '../Clients/PostgresClient'
|
||||
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'
|
||||
import type DaoAdapter from './DaoAdapter'
|
||||
import type { DBPull } from './DaoAdapter'
|
||||
|
||||
export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
const specialKeywords = ['user', 'end'] as const
|
||||
|
||||
export default class PostgresAdapter<T extends Schema> implements DaoAdapter<T> {
|
||||
|
||||
private id: Array<string> = []
|
||||
|
||||
@ -19,7 +21,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
/**
|
||||
* the schema used by Cassandra
|
||||
*/
|
||||
public readonly schema: Schema<T>,
|
||||
public readonly schema: T,
|
||||
/**
|
||||
* the table name
|
||||
*/
|
||||
@ -36,43 +38,51 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
) {
|
||||
objectLoop(this.schema.model, (schema, key) => {
|
||||
if (!isSchemaItem(schema)) {
|
||||
return
|
||||
}
|
||||
if (schema.database?.auto) {
|
||||
if (schema.attributes.includes('db:auto')) {
|
||||
this.id.push(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: make it clearer what it does
|
||||
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
console.log(obj)
|
||||
public async create(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
// handle automated values
|
||||
objectLoop(this.schema.model, (item, key) => {
|
||||
if (isSchemaItem(item) && (item.database?.created || item.database?.updated)) {
|
||||
if (item.attributes.includes('db:created') || item.attributes.includes('db: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) {
|
||||
} else if (item.attributes.includes('db:auto') && !obj[key]) {
|
||||
if (item.isOfType('')) {
|
||||
// @ts-expect-error things get validated anyway
|
||||
obj[key] = crypto.randomBytes(16).toString('hex')
|
||||
} else {
|
||||
} else if (item.isOfType(123)) {
|
||||
// @ts-expect-error things get validated anyway
|
||||
obj[key] = crypto.randomBytes(16).readUint32BE()
|
||||
} else {
|
||||
throw new Error('cannot generate ID because it is not compatible with it')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const clone = this.schema.parse(obj)
|
||||
if (!clone) {
|
||||
// parse the data with the Schema
|
||||
const { object: clone, error} = this.schema.validate(obj)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw new Error('Invalid data given to create the final object')
|
||||
}
|
||||
|
||||
// prepare the database query
|
||||
const keys = objectKeys(clone)
|
||||
.map((it) => {
|
||||
if (specialKeywords.includes(it)) { // handle the special keyword
|
||||
return `"${it}"`
|
||||
}
|
||||
return it
|
||||
})
|
||||
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 client = await PostgresClient.get()
|
||||
|
||||
const params = objectMap(clone as any, (value, key) => this.valueToDB(key as any, value))
|
||||
|
||||
@ -80,28 +90,31 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
console.log(req, params)
|
||||
}
|
||||
|
||||
// send to the database
|
||||
try {
|
||||
await client.query(req, params)
|
||||
await client.execute(req, params)
|
||||
} catch (e) {
|
||||
console.log(e, req, params)
|
||||
return null
|
||||
}
|
||||
return this.schema.parse(clone)
|
||||
return this.schema.validate(clone).object ?? null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
|
||||
public async read(query?: Query<SchemaInfer<T>> | undefined): Promise<DBPull<T>> {
|
||||
// prepare the request to the database based on the query parameters
|
||||
let req: Array<string> = ['SELECT', '*', 'FROM', this.table]
|
||||
|
||||
const client = (await Client.get())!
|
||||
const client = await PostgresClient.get()
|
||||
|
||||
if (this.options?.debug) {
|
||||
console.log(req)
|
||||
}
|
||||
|
||||
let res: QueryResult<any> | undefined
|
||||
// read from the database
|
||||
let res: Array<Record<string, any>>
|
||||
try {
|
||||
res = await client.query(`${req.join(' ')}`)
|
||||
res = await client.execute(`${req.join(' ')}`)
|
||||
} catch (error) {
|
||||
console.error('error running request')
|
||||
console.error(req)
|
||||
@ -117,22 +130,28 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
}
|
||||
|
||||
const raw = 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] = []
|
||||
}
|
||||
})
|
||||
if (this.options?.debug) {
|
||||
console.log('preEdits', res)
|
||||
}
|
||||
|
||||
return obj
|
||||
// post-process the data from the database
|
||||
const raw = res
|
||||
.map((obj) => {
|
||||
// remap to use system value instead of db values
|
||||
obj = objectRemap(this.schema.model, (_, key) => ({
|
||||
key,
|
||||
value: this.dbToValue(key as any, (obj as any)[key])
|
||||
}))
|
||||
|
||||
// validate the schema
|
||||
const res = this.schema.validate(obj)
|
||||
if (res.object) {
|
||||
return res.object
|
||||
}
|
||||
console.log(res.error)
|
||||
return null
|
||||
})
|
||||
.map((it) => this.schema.parse(it))
|
||||
.filter((it): it is Implementation<T> => !!it)
|
||||
.filter((it): it is SchemaInfer<T> => !!it)
|
||||
|
||||
// temp modification of comportement to use the new and better query system
|
||||
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
||||
@ -145,13 +164,18 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
}
|
||||
let dataset = raw
|
||||
if (query) {
|
||||
dataset = filter(query, dataset, this.options)
|
||||
|
||||
|
||||
if (this.options?.debug) {
|
||||
console.log('preFilters', dataset)
|
||||
}
|
||||
|
||||
if (query) {
|
||||
dataset = filter(query, dataset, this.options).filtered
|
||||
}
|
||||
// console.log(res)
|
||||
return {
|
||||
rows: dataset.length ?? 0,
|
||||
rowsTotal: res.rowCount ?? 0,
|
||||
rowsTotal: res.length ?? 0,
|
||||
page: 1,
|
||||
pageTotal: 1,
|
||||
// page: page,
|
||||
@ -160,25 +184,32 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
}
|
||||
|
||||
public async update(obj: Implementation<T>): Promise<Implementation<T> | null> {
|
||||
public async update(obj: SchemaInfer<T>): Promise<SchemaInfer<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>
|
||||
public async patch(id: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
|
||||
public async patch(id: string, obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
|
||||
// eslint-disable-next-line complexity
|
||||
public async patch(id: string | Partial<Implementation<T>>, obj?: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
|
||||
public async patch(id: string | Partial<SchemaInfer<T>>, obj?: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
|
||||
|
||||
if (!obj) {
|
||||
if (typeof id === 'string') {
|
||||
return null
|
||||
}
|
||||
obj = {...id} as Partial<Implementation<T>>
|
||||
obj = {...id} as Partial<SchemaInfer<T>>
|
||||
}
|
||||
|
||||
// const tmp = this.schema.validate(obj)
|
||||
// // if (tmp.error) {
|
||||
// // throw new Error(`obj invalid can\'t patch ${JSON.stringify(tmp.error)}`)
|
||||
// // }
|
||||
|
||||
// obj = tmp.object
|
||||
|
||||
// update the updated time
|
||||
objectLoop(this.schema.model, (item, key) => {
|
||||
if (isSchemaItem(item) && item.database?.updated) {
|
||||
if (item.attributes.includes('db:updated')) {
|
||||
// @ts-expect-error things get validated anyway
|
||||
obj[key] = new Date()
|
||||
}
|
||||
@ -195,7 +226,13 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
|
||||
// map the items to update
|
||||
const keys = objectMap(obj as {}, (_, key, idx) => `${key}=$${idx+1}`)
|
||||
const keys = objectMap(obj as {}, (_, key, idx) => {
|
||||
if (specialKeywords.includes(key)) {
|
||||
return `"${key}"=$${idx+1}`
|
||||
}
|
||||
|
||||
return `${key}=$${idx+1}`
|
||||
})
|
||||
parts.push(keys.join(', '))
|
||||
params.push(...objectValues(obj as {}))
|
||||
|
||||
@ -210,7 +247,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
parts.push(`${key}=$${params.length+1}`)
|
||||
const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
|
||||
read[key] = this.valueToDB(key, value)
|
||||
read[key] = this.valueToDB(key as any, value)
|
||||
if (!value) {
|
||||
throw new Error(`Missing id (${key})`)
|
||||
}
|
||||
@ -218,14 +255,14 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
|
||||
const req = parts.join(' ')
|
||||
const client = await Client.get()
|
||||
const client = await PostgresClient.get()
|
||||
|
||||
if (this.options?.debug) {
|
||||
console.log(req, params)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await client!.query(req, params)
|
||||
const res = await client!.execute(req, params)
|
||||
// console.log(res, req)
|
||||
if (this.options?.debug) {
|
||||
console.log('post patch result', res, req)
|
||||
@ -237,22 +274,34 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
return null
|
||||
}
|
||||
|
||||
public async delete(obj: Implementation<T>): Promise<boolean> {
|
||||
public async delete(obj: SchemaInfer<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}`)
|
||||
if (specialKeywords.includes(key)) {
|
||||
// @ts-expect-error gnagnagna
|
||||
key = `"${key}"`
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
parts.push(`${key} = '${value}'`)
|
||||
break
|
||||
|
||||
default:
|
||||
parts.push(`${key} = ${value}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const client = await Client.get()
|
||||
const client = await PostgresClient.get()
|
||||
|
||||
if (this.options?.debug) {}
|
||||
|
||||
try {
|
||||
await client!.query(`${parts.join(' ')}`)
|
||||
await client!.execute(`${parts.join(' ')}`)
|
||||
} catch (e) {
|
||||
console.error(e, parts)
|
||||
throw e
|
||||
@ -261,30 +310,24 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
||||
}
|
||||
|
||||
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
|
||||
const item: SchemaItem<unknown> = (this.schema.model as any)[key]
|
||||
|
||||
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
||||
if (item.isOfType({})) {
|
||||
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
|
||||
const item: SchemaItem<unknown> = (this.schema.model as any)[key]
|
||||
|
||||
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
||||
return JSON.parse(value as string)
|
||||
if (item.isOfType(543) && typeof value === 'string') {
|
||||
return parseFloat(value)
|
||||
}
|
||||
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
return value
|
||||
if (item.isOfType({}) && typeof value === 'string') {
|
||||
return JSON.parse(value)
|
||||
}
|
||||
|
||||
return value
|
||||
|
130
src/models/Clients/CassandraClient.ts
Normal file
130
src/models/Clients/CassandraClient.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { objectRemap } from '@dzeio/object-util'
|
||||
import Cassandra from 'cassandra-driver'
|
||||
import { getEnv, requireEnv } from 'libs/Env'
|
||||
import Client from '.'
|
||||
|
||||
export default class CassandraClient extends Client {
|
||||
|
||||
private static instance: CassandraClient | null = null
|
||||
private client?: Cassandra.Client | null = null
|
||||
|
||||
|
||||
public async getVersion(): Promise<number> {
|
||||
try {
|
||||
await this.execute(`USE ${requireEnv('CASSANDRA_DATABASE')}`)
|
||||
} catch (e) {
|
||||
// database not found
|
||||
console.log('database not found', e)
|
||||
return -1
|
||||
}
|
||||
try {
|
||||
const res = await this.execute('SELECT value FROM settings WHERE id = \'db_version\'')
|
||||
const value = res[0]?.value
|
||||
if (value.includes('T')) {
|
||||
return new Date(value).getTime()
|
||||
}
|
||||
return Number.parseInt(value)
|
||||
} catch (e) {
|
||||
// table does not exists
|
||||
console.log('Settings table does not exists', e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
public override async setVersion(version: number): Promise<void> {
|
||||
await this.execute(`
|
||||
UPDATE settings SET value = ? WHERE id = 'db_version';
|
||||
`.trim(), [version.toString()])
|
||||
}
|
||||
|
||||
public async execute(query: string, params?: Array<unknown> | object, options?: Cassandra.QueryOptions): Promise<Array<Record<string, any>>> {
|
||||
if (!this.client || this.client.getState().getConnectedHosts().length === 0) {
|
||||
throw new Error('not connected to the database !')
|
||||
}
|
||||
|
||||
const res = await this.client.execute(query, params, options)
|
||||
// if (query.includes('users'))
|
||||
// console.log(res)
|
||||
|
||||
|
||||
return res.rows?.map((it) => objectRemap(it.keys(), (key: string) => ({ key: key, value: it.get(key) }))) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* get the connexion to cassandra, it will try until it succedeed
|
||||
*/
|
||||
public static async get() {
|
||||
const client = CassandraClient.instance ?? new CassandraClient()
|
||||
CassandraClient.instance = client
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* connect to Cassandra
|
||||
*/
|
||||
|
||||
public async connect() {
|
||||
if (await this.isReady()) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('connecting to cassandra')
|
||||
let authProvider: Cassandra.auth.AuthProvider | undefined
|
||||
|
||||
const method = getEnv('CASSANDRA_AUTH_METHOD')
|
||||
if (method) {
|
||||
|
||||
switch (method.toLowerCase()) {
|
||||
case 'passwordauthenticator':
|
||||
case 'plaintext':
|
||||
authProvider = new Cassandra.auth.PlainTextAuthProvider(
|
||||
requireEnv('CASSANDRA_USERNAME'),
|
||||
requireEnv('CASSANDRA_PASSWORD')
|
||||
)
|
||||
break
|
||||
case 'dseplaintext':
|
||||
authProvider = new Cassandra.auth.DsePlainTextAuthProvider(
|
||||
requireEnv('CASSANDRA_USERNAME'),
|
||||
requireEnv('CASSANDRA_PASSWORD'),
|
||||
getEnv('CASSANDRA_AUTHORIZATION_ID')
|
||||
)
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
default:
|
||||
console.error('Please use a valid CASSANDRA_AUTH_METHOD value (none|plaintext|dseplaintext)')
|
||||
throw new Error('Please use a valid CASSANDRA_AUTH_METHOD value (none|plaintext|dseplaintext)')
|
||||
}
|
||||
}
|
||||
|
||||
this.client = new Cassandra.Client({
|
||||
contactPoints: [requireEnv('CASSANDRA_CONTACT_POINT')],
|
||||
authProvider: authProvider as Cassandra.auth.AuthProvider,
|
||||
localDataCenter: getEnv('CASSANDRA_LOCAL_DATA_CENTER', 'datacenter1')
|
||||
})
|
||||
// this.client.on('log', (level, loggerName, message, furtherInfo) => {
|
||||
// console.log(`${level} - ${loggerName}: ${message}`);
|
||||
// })
|
||||
|
||||
try {
|
||||
await this.client.connect()
|
||||
} catch (e) {
|
||||
this.client = null
|
||||
console.error(e)
|
||||
throw new Error('Error connecting to Cassandra')
|
||||
}
|
||||
// try {
|
||||
// await Migration.migrateToLatest()
|
||||
// } catch (e) {
|
||||
// this.migrated = -1
|
||||
// console.error(e)
|
||||
// throw new Error('An error occured while migrating')
|
||||
// }
|
||||
// this.migrated = 1
|
||||
|
||||
}
|
||||
|
||||
public async isReady(): Promise<boolean> {
|
||||
return !!this.client && this.client.getState().getConnectedHosts().length >= 1
|
||||
}
|
||||
}
|
@ -1,91 +1,70 @@
|
||||
import { wait } from 'libs/AsyncUtils'
|
||||
import { getEnv, requireEnv } from 'libs/Env'
|
||||
import pg from 'pg'
|
||||
import Migration from '../Migrations'
|
||||
import Client from '.'
|
||||
const Postgres = pg.Client
|
||||
|
||||
export default class Client {
|
||||
private static client?: pg.Client | null
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
|
||||
export default class PostgresClient extends Client {
|
||||
private static instance: PostgresClient = new PostgresClient()
|
||||
private client?: pg.Client | null
|
||||
public override async getVersion(): Promise<number> {
|
||||
try {
|
||||
const res = await this.execute(`SELECT value FROM settings WHERE id = 'db_version'`)
|
||||
|
||||
const value = res[0]?.value
|
||||
if (!value) {
|
||||
return -1
|
||||
}
|
||||
return Number.parseInt(value)
|
||||
} catch (e) {
|
||||
// table does not exists
|
||||
console.log('Settings table does not exists', e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
public override async setVersion(version: number): Promise<void> {
|
||||
await this.execute(`UPDATE settings SET value = $1 WHERE id = 'db_version';`, [version.toString()])
|
||||
}
|
||||
public override async execute(query: string, params?: Array<unknown> | object, ...options: Array<any>): Promise<Array<Record<string, unknown>>> {
|
||||
if (!this.client || !await this.isReady()) {
|
||||
throw new Error('not connected')
|
||||
}
|
||||
const res = await this.client.query<Record<string, unknown>>(query, params)
|
||||
return res.rows
|
||||
}
|
||||
public override async connect(): Promise<void> {
|
||||
if (this.client) {
|
||||
return
|
||||
}
|
||||
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}`);
|
||||
// },
|
||||
})
|
||||
.on('end', () => {
|
||||
this.client = null
|
||||
})
|
||||
try {
|
||||
await this.client.connect()
|
||||
} catch (e) {
|
||||
this.client = null
|
||||
console.error(e)
|
||||
throw new Error('Error connecting to Postgres')
|
||||
}
|
||||
}
|
||||
public override async isReady(): Promise<boolean> {
|
||||
return !!this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public static async get() {
|
||||
return PostgresClient.instance
|
||||
}
|
||||
}
|
||||
|
141
src/models/Clients/index.ts
Normal file
141
src/models/Clients/index.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import config from 'models/config'
|
||||
import type MigrationObj from 'models/Migrations'
|
||||
|
||||
export enum ConnectionStatus {
|
||||
DISCONNECTED,
|
||||
MIGRATING,
|
||||
READY
|
||||
}
|
||||
|
||||
export interface ClientStatic<C extends Client = Client> {
|
||||
get(): Promise<C>
|
||||
}
|
||||
|
||||
export default abstract class Client {
|
||||
|
||||
|
||||
public status: ConnectionStatus = ConnectionStatus.DISCONNECTED
|
||||
|
||||
/**
|
||||
* -1: unknown
|
||||
* 0: migrating
|
||||
* 1: migrated
|
||||
*/
|
||||
public migrationStatus = -1
|
||||
|
||||
/**
|
||||
* get the current migration version
|
||||
*
|
||||
* -1 nothing/error
|
||||
* 0+ current migration
|
||||
*/
|
||||
public abstract getVersion(): Promise<number>
|
||||
public abstract setVersion(version: number): Promise<void>
|
||||
|
||||
public abstract execute(query: string, params?: Array<unknown> | object, ...options: Array<any>): Promise<Array<Record<string, unknown>>>
|
||||
|
||||
public abstract connect(): Promise<void>
|
||||
|
||||
/**
|
||||
* Migrate the database to the latest version
|
||||
*/
|
||||
public async migrateToLatest() {
|
||||
const migrations = this.getMigrations()
|
||||
const latest = migrations[migrations.length - 1]
|
||||
if (!latest) {
|
||||
return
|
||||
}
|
||||
return await this.migrateTo(latest.date)
|
||||
}
|
||||
|
||||
public getMigrations(): ReadonlyArray<MigrationObj> {
|
||||
return config.migrations as ReadonlyArray<MigrationObj>
|
||||
}
|
||||
|
||||
/**
|
||||
* migrate to a specific date in time
|
||||
* @param newVersion the date to try to migrate to
|
||||
*/
|
||||
public async migrateTo(newVersion: number) {
|
||||
if (this.migrationStatus === 0) {
|
||||
return
|
||||
}
|
||||
this.migrationStatus = 0
|
||||
|
||||
let currentVersion = await this.getVersion() ?? -1
|
||||
|
||||
const migrations = this.getMigrations()
|
||||
|
||||
// same version, don't to anything
|
||||
if (newVersion === currentVersion) {
|
||||
this.migrationStatus = 1
|
||||
return
|
||||
}
|
||||
console.log('\x1b[35mCurrent DB version', currentVersion, '\x1b[0m')
|
||||
|
||||
// run up migrations
|
||||
if (currentVersion < newVersion) {
|
||||
console.log('\x1b[35m', 'Migrating up to', newVersion, '\x1b[0m')
|
||||
const migrationsToRun = migrations.filter((it) => it.date > currentVersion && it.date <= newVersion)
|
||||
for (const migration of migrationsToRun) {
|
||||
console.log('\x1b[35m', 'Migrating from', currentVersion, 'to', migration.date, '\x1b[0m')
|
||||
await migration.up(this)
|
||||
await this.setVersion(migration.date)
|
||||
currentVersion = migration.date
|
||||
}
|
||||
} else if (currentVersion > newVersion) { // run down migrations
|
||||
console.log('\x1b[35m', 'Migrating down to', newVersion, '\x1b[0m')
|
||||
const migrationsToRun = migrations.filter((it) => it.date < currentVersion && it.date >= newVersion)
|
||||
.toReversed()
|
||||
for (const migration of migrationsToRun) {
|
||||
console.log('\x1b[35m', 'Migrating from', currentVersion, 'to', migration.date, '\x1b[0m')
|
||||
await migration.down?.(this)
|
||||
await this.setVersion(migration.date)
|
||||
currentVersion = migration.date
|
||||
}
|
||||
}
|
||||
console.log('\x1b[32mDone migrating\x1b[0m')
|
||||
this.migrationStatus = 1
|
||||
}
|
||||
|
||||
// public getStatus(): Promise<ClientStatus>
|
||||
|
||||
// public abstract isMigrated(): Promise<boolean>
|
||||
|
||||
/**
|
||||
* indicate if the client is ready for new requests (not if migrations are done or not)
|
||||
*/
|
||||
public abstract isReady(): Promise<boolean>
|
||||
|
||||
/**
|
||||
* wait until every migrations are done or fail
|
||||
*/
|
||||
public async waitForMigrations(): Promise<void> {
|
||||
if (this.migrationStatus === -1) {
|
||||
await this.migrateToLatest()
|
||||
}
|
||||
while (!await this.isMigrated()) {
|
||||
console.log('waiting...')
|
||||
await new Promise((res) => setTimeout(res, 100))
|
||||
}
|
||||
}
|
||||
|
||||
public isMigrating(): boolean {
|
||||
return this.migrationStatus === 0
|
||||
}
|
||||
|
||||
public async isMigrated(): Promise<boolean> {
|
||||
return this.migrationStatus === 1
|
||||
// if (this.migrationStatus < 1) {
|
||||
// return false
|
||||
// } else if (this.migrationStatus === 1) {
|
||||
// return
|
||||
// }
|
||||
// const migrations = this.getMigrations()
|
||||
// const last = migrations[migrations.length - 1]
|
||||
// if (!last) {
|
||||
// return true
|
||||
// }
|
||||
// return last.date === await this.getVersion()
|
||||
}
|
||||
}
|
@ -1,21 +1,20 @@
|
||||
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'
|
||||
import type Schema from 'libs/Schema'
|
||||
import type { SchemaInfer } from 'libs/Schema'
|
||||
import type DaoAdapter from './Adapters/DaoAdapter'
|
||||
import type { DBPull } from './Adapters/DaoAdapter'
|
||||
import type { Query } from './Query'
|
||||
|
||||
/**
|
||||
* 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 class Dao<S extends Schema<any>> {
|
||||
export default class Dao<S extends Schema = Schema> {
|
||||
|
||||
public constructor(
|
||||
public readonly schema: S,
|
||||
public readonly adapter: DaoAdapter<S['model']>
|
||||
) {}
|
||||
public readonly adapter: DaoAdapter<S>
|
||||
) { }
|
||||
|
||||
/**
|
||||
* insert a new object into the source
|
||||
@ -23,7 +22,7 @@ export default class Dao<S extends Schema<any>> {
|
||||
* @param obj the object to create
|
||||
* @returns the object with it's id filled if create or null otherwise
|
||||
*/
|
||||
public async create(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||
public async create(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
|
||||
if (!this.adapter.create) {
|
||||
throw new Error('the Adapter does not allow you to create elements')
|
||||
}
|
||||
@ -36,8 +35,8 @@ export default class Dao<S extends Schema<any>> {
|
||||
* @param obj the object to create
|
||||
* @returns the object with it's id filled if create or null otherwise
|
||||
*/
|
||||
public async insert(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||
return this.create(obj as any)
|
||||
public async insert(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
|
||||
return this.create(obj)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,12 +45,12 @@ export default class Dao<S extends Schema<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
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
public async findAll(query?: Query<Impl<S>>, ...args: Array<any>): Promise<DBPull<S['model']>> {
|
||||
|
||||
public async findAll(query?: Query<SchemaInfer<S>>): Promise<DBPull<S>> {
|
||||
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)
|
||||
return this.adapter.read(query)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -60,8 +59,8 @@ export default class Dao<S extends Schema<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 async find(query: Parameters<this['findAll']>[0], ...args: Array<any>) {
|
||||
return this.findAll(query, ...args)
|
||||
public async find(query: Parameters<this['findAll']>[0]) {
|
||||
return this.findAll(query)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -72,8 +71,8 @@ export default class Dao<S extends Schema<any>> {
|
||||
* @param id the id of the object
|
||||
* @returns
|
||||
*/
|
||||
public findById(id: any, ...args: Array<any>): Promise<Implementation<S['model']> | null> {
|
||||
return this.findOne({id: id}, ...args)
|
||||
public findById(id: any): Promise<SchemaInfer<S> | null> {
|
||||
return this.findOne({ id: id })
|
||||
}
|
||||
|
||||
/**
|
||||
@ -94,8 +93,8 @@ export default class Dao<S extends Schema<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?: Parameters<this['findAll']>[0], ...args: Array<any>): Promise<Implementation<S['model']> | null> {
|
||||
return (await this.findAll(query, ...args)).data[0] ?? null
|
||||
public async findOne(query?: Parameters<this['findAll']>[0]): Promise<SchemaInfer<S> | null> {
|
||||
return (await this.findAll(query)).data[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,7 +105,7 @@ export default class Dao<S extends Schema<any>> {
|
||||
* @param obj the object to update
|
||||
* @returns an object if it was able to update or null otherwise
|
||||
*/
|
||||
public async update(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||
public async update(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
|
||||
if (!this.adapter.update) {
|
||||
throw new Error('the Adapter does not allow you to update to the remote source')
|
||||
}
|
||||
@ -118,13 +117,13 @@ export default class Dao<S extends Schema<any>> {
|
||||
* @param id the id of the object
|
||||
* @param changes the change to make
|
||||
*/
|
||||
public async patch(id: string, changes: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
|
||||
public async patch(id: string, changes: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
|
||||
if (!this.adapter.patch) {
|
||||
const query = await this.findById(id)
|
||||
if (!query) {
|
||||
return null
|
||||
}
|
||||
return await this.update({...query, ...changes})
|
||||
return await this.update({ ...query, ...changes })
|
||||
}
|
||||
return await this.adapter.patch(id, changes)
|
||||
}
|
||||
@ -134,7 +133,7 @@ export default class Dao<S extends Schema<any>> {
|
||||
* @param obj the object to update/insert
|
||||
* @returns the object is updated/inserted or null otherwise
|
||||
*/
|
||||
public async upsert(object: Partial<Implementation<S['model']>>): Promise<Partial<Implementation<S['model']>> | null> {
|
||||
public async upsert(object: Partial<SchemaInfer<S>>): Promise<Partial<SchemaInfer<S>> | null> {
|
||||
if (!this.adapter.upsert) {
|
||||
throw new Error('the Adapter does not allow you to upsert to the remote source')
|
||||
}
|
||||
@ -147,7 +146,7 @@ export default class Dao<S extends Schema<any>> {
|
||||
*
|
||||
* @returns if the object was deleted or not (if object is not in db it will return true)
|
||||
*/
|
||||
public async delete(obj: Partial<Implementation<S['model']>>): Promise<boolean> {
|
||||
public async delete(obj: Partial<SchemaInfer<S>>): Promise<boolean> {
|
||||
if (!this.adapter.delete) {
|
||||
throw new Error('the Adapter does not allow you to delete on the remote source')
|
||||
}
|
||||
|
@ -1,29 +1,19 @@
|
||||
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
|
||||
*
|
||||
* 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>
|
||||
}
|
||||
import type Dao from './Dao'
|
||||
import config from './config'
|
||||
|
||||
/**
|
||||
* Class to get any DAO
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
|
||||
export default class DaoFactory {
|
||||
/**
|
||||
* reference of the different Daos for a correct singleton implementation
|
||||
* get the total list of daos available
|
||||
* @returns return the list of daos available
|
||||
*/
|
||||
private static daos: Partial<DaoItem> = {}
|
||||
public static getAll(): Record<string, Dao> {
|
||||
return config.models
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a a dao by its key
|
||||
@ -33,30 +23,15 @@ export default class DaoFactory {
|
||||
* @param key the dao key to get
|
||||
* @returns the Dao you want as a singleton
|
||||
*/
|
||||
public static get<Key extends keyof DaoItem>(key: Key): DaoItem[Key] {
|
||||
if (!(key in this.daos)) {
|
||||
const dao = this.initDao(key)
|
||||
if (!dao) {
|
||||
throw new Error(`${key} has no valid Dao`)
|
||||
}
|
||||
this.daos[key] = dao as DaoItem[Key]
|
||||
}
|
||||
return this.daos[key] as DaoItem[Key]
|
||||
public static get<Key extends keyof typeof config['models']>(key: Key): typeof config['models'][Key] {
|
||||
return config.models[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* init a dao by its key, it does not care if it exists or not
|
||||
*
|
||||
* @param item the element to init
|
||||
* @returns a new initialized dao or undefined if no dao is linked
|
||||
* get the main client linked to migrations
|
||||
* @returns the main client
|
||||
*/
|
||||
// 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
|
||||
}
|
||||
public static async client(): ReturnType<(typeof config.mainClient)['get']> {
|
||||
return config.mainClient.get()
|
||||
}
|
||||
}
|
||||
|
18
src/models/History.ts
Normal file
18
src/models/History.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||
|
||||
const schema = new Schema({
|
||||
/**
|
||||
* the project ID
|
||||
*/
|
||||
id: s.string().attr('db:unique', 'db:auto'),
|
||||
issue: s.string(),
|
||||
|
||||
user: s.nullable(s.string()),
|
||||
description: s.string().nullable(),
|
||||
|
||||
created: s.date().attr('db:created')
|
||||
})
|
||||
|
||||
export default schema
|
||||
|
||||
export type History = SchemaInfer<typeof schema>
|
@ -1,42 +1,42 @@
|
||||
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||
import Priority from 'models/Priority'
|
||||
import Schema, { type Impl } from 'models/Schema'
|
||||
|
||||
const schema = new Schema({
|
||||
/**
|
||||
* the project ID
|
||||
*/
|
||||
id: {type: String, database: {unique: true, index: true, auto: true}},
|
||||
localid: Number,
|
||||
project: String,
|
||||
id: s.string().attr('db:unique', 'db:auto'),
|
||||
localid: s.number(),
|
||||
project: s.string(),
|
||||
|
||||
/**
|
||||
* the email the project was created from
|
||||
*/
|
||||
name: { type: String, nullable: true },
|
||||
name: s.string().nullable(),
|
||||
|
||||
description: { type: String, nullable: true },
|
||||
description: s.string().nullable(),
|
||||
|
||||
state: String, //state id
|
||||
priority: {type: Number, defaultValue: Priority.NONE},
|
||||
begin: { type: Date, nullable: true },
|
||||
due: { type: Date, nullable: true },
|
||||
state: s.string(), //state id
|
||||
priority: s.number().defaultValue(Priority.NONE),
|
||||
begin: s.nullable(s.date()),
|
||||
due: s.nullable(s.date()),
|
||||
|
||||
/**
|
||||
* Parent issue
|
||||
*/
|
||||
parent: { type: String, nullable: true },
|
||||
parent: s.string().nullable(),
|
||||
|
||||
/**
|
||||
* issue labels
|
||||
*/
|
||||
labels: [String],
|
||||
labels: s.array(s.string()),
|
||||
|
||||
/**
|
||||
* is the issue archived
|
||||
*/
|
||||
archived: { type: Boolean, defaultValue: false }
|
||||
archived: s.boolean().defaultValue(false)
|
||||
})
|
||||
|
||||
export default schema
|
||||
|
||||
export type ProjectObj = Impl<typeof schema>
|
||||
export type ProjectObj = SchemaInfer<typeof schema>
|
||||
|
@ -1,22 +0,0 @@
|
||||
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
|
10
src/models/Migrations/Migration.d.ts
vendored
Normal file
10
src/models/Migrations/Migration.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
import type Client from 'models/Clients'
|
||||
|
||||
export default interface Migration {
|
||||
/**
|
||||
* timestamp in UTC
|
||||
*/
|
||||
date: number
|
||||
up(client: Client): Promise<boolean>
|
||||
down?(client: Client): Promise<boolean>
|
||||
}
|
22
src/models/Migrations/Migration0.ts
Normal file
22
src/models/Migrations/Migration0.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type Migration from './Migration'
|
||||
|
||||
export default {
|
||||
date: 0,
|
||||
async up(client): Promise<boolean> {
|
||||
await client.execute(`CREATE TABLE IF NOT EXISTS settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);`)
|
||||
|
||||
await client.execute(`INSERT INTO settings (id, value) VALUES ('db_created', ${new Date().getTime().toString()})`)
|
||||
await client.execute(`INSERT INTO settings (id, value) VALUES ('db_version', -1)`)
|
||||
await client.execute(`UPDATE settings SET value = ${new Date().getTime().toString()} WHERE id = 'db_created';`)
|
||||
|
||||
return true
|
||||
},
|
||||
async down(client) {
|
||||
await client.execute(`DROP TABLE settings`)
|
||||
|
||||
return true
|
||||
},
|
||||
} as Migration
|
@ -1,9 +1,9 @@
|
||||
import { type MigrationObj } from '.'
|
||||
import type Migration from "./Migration"
|
||||
|
||||
export default {
|
||||
date: new Date('2024-05-15T09:57:48'),
|
||||
date: Date.UTC(2024, 5 + 1, 15, 9, 57, 48),
|
||||
async up(client): Promise<boolean> {
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS project (
|
||||
await client.execute(`CREATE TABLE IF NOT EXISTS project (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
@ -12,7 +12,7 @@ export default {
|
||||
archived BOOL
|
||||
);`)
|
||||
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS state (
|
||||
await client.execute(`CREATE TABLE IF NOT EXISTS state (
|
||||
id TEXT PRIMARY KEY,
|
||||
project TEXT,
|
||||
name TEXT,
|
||||
@ -20,7 +20,7 @@ export default {
|
||||
preset BOOL
|
||||
)`)
|
||||
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS issue (
|
||||
await client.execute(`CREATE TABLE IF NOT EXISTS issue (
|
||||
id TEXT PRIMARY KEY,
|
||||
localID INT,
|
||||
project TEXT,
|
||||
@ -38,10 +38,10 @@ export default {
|
||||
return true
|
||||
},
|
||||
async down(client) {
|
||||
await client.query(`DROP TABLE project`)
|
||||
await client.query(`DROP TABLE state`)
|
||||
await client.query(`DROP TABLE issue`)
|
||||
await client.execute(`DROP TABLE project`)
|
||||
await client.execute(`DROP TABLE state`)
|
||||
await client.execute(`DROP TABLE issue`)
|
||||
|
||||
return true
|
||||
},
|
||||
} as MigrationObj
|
||||
} as Migration
|
@ -1,98 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
@ -1,22 +1,22 @@
|
||||
import Schema, { type Impl } from 'models/Schema'
|
||||
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||
|
||||
const schema = new Schema({
|
||||
/**
|
||||
* the project ID
|
||||
*/
|
||||
id: {type: String, database: {unique: true, index: true, auto: true}},
|
||||
id: s.string().attr('db:unique', 'db:auto'),
|
||||
/**
|
||||
* the email the project was created from
|
||||
*/
|
||||
name: { type: String, nullable: true },
|
||||
name: s.string().nullable(),
|
||||
|
||||
description: { type: String, nullable: true },
|
||||
description: s.string().nullable(),
|
||||
|
||||
displayid: { type: String, nullable: true },
|
||||
visibility: { type: String, nullable: true },
|
||||
archived: { type: Boolean, defaultValue: false }
|
||||
displayid: s.string().nullable(),
|
||||
visibility: s.string().nullable(),
|
||||
archived: s.boolean().defaultValue(false)
|
||||
})
|
||||
|
||||
export default schema
|
||||
|
||||
export type ProjectObj = Impl<typeof schema>
|
||||
export type ProjectObj = SchemaInfer<typeof schema>
|
||||
|
@ -1,4 +1,4 @@
|
||||
interface QueryRootFilters<Obj extends Record<string, any>> {
|
||||
interface QueryRootFilters<Obj extends Record<string, unknown>> {
|
||||
/**
|
||||
* one of the results should be true to be true
|
||||
*/
|
||||
@ -108,7 +108,7 @@ export type QueryComparisonOperator<Value> = {
|
||||
$inc: Value | null
|
||||
}
|
||||
|
||||
export type QueryList<Obj extends Record<string, any>> = {
|
||||
export type QueryList<Obj extends Record<string, unknown>> = {
|
||||
[Key in keyof Obj]?: QueryValues<Obj[Key]>
|
||||
}
|
||||
|
||||
@ -128,12 +128,12 @@ export type QueryValues<Value> = Value |
|
||||
/**
|
||||
* The query element that allows you to query different elements
|
||||
*/
|
||||
export type Query<Obj extends Record<string, any>> = QueryList<Obj> & QueryRootFilters<Obj>
|
||||
export type Query<Obj extends Record<string, unknown>> = QueryList<Obj> & QueryRootFilters<Obj>
|
||||
|
||||
/**
|
||||
* sorting interface with priority
|
||||
*/
|
||||
export type SortInterface<Obj extends Record<string, any>> = {
|
||||
export type SortInterface<Obj extends Record<string, unknown>> = {
|
||||
[Key in keyof Obj]?: Sort
|
||||
}
|
||||
|
||||
|
@ -1,588 +0,0 @@
|
||||
/* 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,22 +1,22 @@
|
||||
import Schema, { type Impl } from 'models/Schema'
|
||||
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||
|
||||
const schema = new Schema({
|
||||
/**
|
||||
* the project ID
|
||||
*/
|
||||
id: {type: String, database: {unique: true, index: true, auto: true}},
|
||||
project: String, // project id
|
||||
id: s.string().attr('db:unique', 'db:auto'),
|
||||
project: s.string(), // project id
|
||||
|
||||
/**
|
||||
* the email the project was created from
|
||||
*/
|
||||
name: { type: String, nullable: true },
|
||||
name: s.string().nullable(),
|
||||
|
||||
color: { type: String, nullable: true },
|
||||
color: s.string().nullable(),
|
||||
|
||||
preset: { type: Boolean, defaultValue: false }
|
||||
preset: s.boolean().defaultValue(false)
|
||||
})
|
||||
|
||||
export default schema
|
||||
|
||||
export type StateObj = Impl<typeof schema>
|
||||
export type StateObj = SchemaInfer<typeof schema>
|
||||
|
49
src/models/config.ts
Normal file
49
src/models/config.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { ClientStatic } from './Clients'
|
||||
import PostgresClient from './Clients/PostgresClient'
|
||||
import Dao from './Dao'
|
||||
import Project from './Project'
|
||||
|
||||
import PostgresAdapter from './Adapters/PostgresAdapter'
|
||||
import History from './History'
|
||||
import Issue from './Issue'
|
||||
import type Migration from './Migrations/Migration'
|
||||
import Migration0 from './Migrations/Migration0'
|
||||
import Migration20240515T95748 from './Migrations/Migration2024-05-15T9-57-48'
|
||||
import State from './State'
|
||||
|
||||
// @ts-ignore
|
||||
interface Config {
|
||||
mainClient: ClientStatic
|
||||
models: Record<string, Dao>
|
||||
migrations: Array<Migration>
|
||||
|
||||
}
|
||||
|
||||
const config = {
|
||||
mainClient: PostgresClient as ClientStatic<PostgresClient>,
|
||||
migrations: ([
|
||||
Migration0,
|
||||
Migration20240515T95748,
|
||||
{
|
||||
date: Date.UTC(2024, 10, 4),
|
||||
async up(client) {
|
||||
await client.execute(`CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
issue TEXT,
|
||||
"user" TEXT,
|
||||
created DATE
|
||||
)`)
|
||||
}
|
||||
}
|
||||
] as Array<Migration>).sort((a, b) => a.date - b.date),
|
||||
models: {
|
||||
project: new Dao(Project, new PostgresAdapter(Project, 'project')),
|
||||
issue: new Dao(Issue, new PostgresAdapter(Issue, 'issue')),
|
||||
state: new Dao(State, new PostgresAdapter(State, 'state')),
|
||||
history: new Dao(History, new PostgresAdapter(History, 'history'))
|
||||
|
||||
}
|
||||
} as const
|
||||
|
||||
export default config
|
@ -1,4 +1,4 @@
|
||||
import { objectOmit } from '@dzeio/object-util'
|
||||
import { objectMap, objectOmit } from '@dzeio/object-util'
|
||||
import type { APIRoute } from 'astro'
|
||||
import ResponseBuilder from 'libs/ResponseBuilder'
|
||||
import DaoFactory from 'models/DaoFactory'
|
||||
@ -9,14 +9,26 @@ export const GET: APIRoute = async (ctx) => {
|
||||
const dao = DaoFactory.get('issue')
|
||||
|
||||
return new ResponseBuilder()
|
||||
.body((await dao.findOne({project: projectId, id: taskId})))
|
||||
.body((await dao.findOne({ project: projectId, id: taskId })))
|
||||
.build()
|
||||
}
|
||||
export const POST: APIRoute = async (ctx) => {
|
||||
const history = DaoFactory.get('history')
|
||||
const taskId = ctx.params.issueId!
|
||||
const dao = DaoFactory.get('issue')
|
||||
const body = objectOmit(await ctx.request.json(), 'label')
|
||||
const res = await dao.patch(taskId, {id: taskId, ...body})
|
||||
const req = await dao.get(taskId)
|
||||
const res = await dao.patch(taskId, { id: taskId, ...body })
|
||||
await history.create({
|
||||
user: '',
|
||||
description: objectMap(body, (value, key) => {
|
||||
if (req![key as 'id'] === value) {
|
||||
return null
|
||||
}
|
||||
return `${String(key)} changed to ${value}`
|
||||
}).filter((it) => !!it).join('\n'),
|
||||
issue: taskId
|
||||
})
|
||||
|
||||
return new ResponseBuilder()
|
||||
.body(res)
|
||||
|
19
src/pages/api/v1/projects/[id]/issues/[issueId]/details.ts
Normal file
19
src/pages/api/v1/projects/[id]/issues/[issueId]/details.ts
Normal file
@ -0,0 +1,19 @@
|
||||
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')
|
||||
const historyDao = DaoFactory.get('history')
|
||||
const task = (await dao.findOne({ project: projectId, id: taskId }))
|
||||
const history = await historyDao.findAll({ issue: taskId })
|
||||
|
||||
return new ResponseBuilder()
|
||||
.body({
|
||||
issue: task,
|
||||
history: history.data
|
||||
})
|
||||
.build()
|
||||
}
|
@ -8,20 +8,37 @@ 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({
|
||||
let { rows: issueCount } = await dao.findAll({
|
||||
project: projectId
|
||||
})
|
||||
const defaultState = await stateDao.findOne({
|
||||
project: projectId,
|
||||
preset: true
|
||||
})
|
||||
const res = await dao.create({
|
||||
...(await ctx.request.json()),
|
||||
project: projectId,
|
||||
localid: issueCount.rows + 1,
|
||||
state: defaultState?.id ?? 'empty',
|
||||
labels: []
|
||||
})
|
||||
const json = await ctx.request.json()
|
||||
|
||||
const isMultiple = json.multiple
|
||||
delete json.multiple
|
||||
|
||||
let tasks: Array<string> = [json.name]
|
||||
if (isMultiple) {
|
||||
tasks = json.name.split('\n')
|
||||
}
|
||||
delete json.name
|
||||
|
||||
const res: Array<any> = []
|
||||
|
||||
for (const task of tasks) {
|
||||
res.push(await dao.create({
|
||||
...json,
|
||||
name: task,
|
||||
project: projectId,
|
||||
localid: issueCount++,
|
||||
state: defaultState?.id ?? 'empty',
|
||||
labels: []
|
||||
}))
|
||||
}
|
||||
|
||||
return new ResponseBuilder().body(res).build()
|
||||
}
|
||||
|
||||
@ -31,13 +48,14 @@ export const GET: APIRoute = async (ctx) => {
|
||||
const dao = DaoFactory.get('issue')
|
||||
const stateDao = DaoFactory.get('state')
|
||||
const states = (await stateDao.findAll({ project: projectId })).data
|
||||
const issues = (await dao.findAll({project: projectId, $sort: { [sort]: Sort.ASC}})).data
|
||||
const issues = (await dao.findAll({ project: projectId, $sort: { [sort]: Sort.ASC } })).data
|
||||
|
||||
const res = issues.map((it) => ({
|
||||
...it,
|
||||
state: states.find((st) => st.id === it.state),
|
||||
priority: getPriorityText(it.priority ?? Priority.NONE)
|
||||
}))
|
||||
|
||||
return new ResponseBuilder()
|
||||
.body(res)
|
||||
.build()
|
||||
|
@ -4,7 +4,7 @@ import Input from 'components/global/Input.astro'
|
||||
import Select from 'components/global/Select/index.astro'
|
||||
import MainLayout from 'layouts/MainLayout.astro'
|
||||
import DaoFactory from 'models/DaoFactory'
|
||||
import Schema from 'models/Schema'
|
||||
import Schema, { s } from 'libs/Schema'
|
||||
import type { StateObj } from 'models/State'
|
||||
import route from 'route'
|
||||
|
||||
@ -26,7 +26,7 @@ const defaultStates: Array<Partial<StateObj>> = [{
|
||||
|
||||
if (Astro.request.method === 'POST') {
|
||||
const input = new Schema({
|
||||
name: String
|
||||
name: s.string()
|
||||
}).parseFormData(await Astro.request.formData())
|
||||
if (input) {
|
||||
const project = await dao.create({
|
||||
@ -62,13 +62,14 @@ const projects = await dao.findAll()
|
||||
</form>
|
||||
<form action="">
|
||||
<Select
|
||||
multiple
|
||||
options={projects.data.map((it) => ({value: it.id, title: it.name}))}
|
||||
autocomplete
|
||||
name="name"
|
||||
data-input="/api/v1/projects"
|
||||
data-output="#projectItem ul#results inner"
|
||||
data-trigger="keydown load after:100"
|
||||
data-multiple
|
||||
options={projects.data.map((it) => ({value: it.id, title: it.name}))}
|
||||
hyp:action1="/api/v1/projects"
|
||||
hyp:action2="#projectItem ul#results inner"
|
||||
hyp:trigger="load after:100"
|
||||
hyp:multiple
|
||||
data-debug
|
||||
/>
|
||||
</form>
|
||||
|
||||
@ -83,9 +84,3 @@ const projects = await dao.findAll()
|
||||
</li>
|
||||
</template>
|
||||
</MainLayout>
|
||||
|
||||
<script>
|
||||
import Hyperion from 'libs/Hyperion'
|
||||
|
||||
Hyperion.setup()
|
||||
</script>
|
||||
|
@ -44,18 +44,19 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
||||
|
||||
<MainLayout>
|
||||
<main class="container gap-24 md:mt-6">
|
||||
<!-- <div class="w-1/3">
|
||||
<Input
|
||||
name="displayID"
|
||||
value={project.displayid}
|
||||
data-trigger="input after:500"
|
||||
data-input={`post:${route('/api/v1/projects/[id]', {id: project.id})}`}
|
||||
data-output="run:reload"
|
||||
/>
|
||||
</div> -->
|
||||
<h1 class="text-6xl text-center font-bold">{project.name}</h1>
|
||||
<Select data-trigger="change" data-output="hyp:tbody[data-input]" name="sort" options={[{value: 'localid', title: 'ID'}, {value: 'state', title: 'State'}]} />
|
||||
<form
|
||||
id="form"
|
||||
>
|
||||
<Select
|
||||
name="sort"
|
||||
hyp:trigger="change"
|
||||
hyp:action="hyp:tbody[hyp\\:action0]"
|
||||
options={[{value: 'localid', title: 'ID'}, {value: 'state', title: 'State'}]}
|
||||
/>
|
||||
</form>
|
||||
<Table class="table-fixed">
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1/12">id</th>
|
||||
@ -67,17 +68,18 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
data-input={route('/api/v1/projects/[id]/issues', {id: project.id})}
|
||||
data-multiple
|
||||
data-trigger="load"
|
||||
data-output="template#issue"
|
||||
hyp:action0="load:form#form"
|
||||
hyp:action1={route('/api/v1/projects/[id]/issues', {id: project.id})}
|
||||
hyp:action2="template#issue"
|
||||
hyp:multiple
|
||||
hyp:trigger="load"
|
||||
></tbody>
|
||||
</Table>
|
||||
<template id="issue">
|
||||
<tr
|
||||
class="cursor-pointer"
|
||||
data-output="#issueTemplate #issue-details inner"
|
||||
data-input={route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}
|
||||
data-input={route('/api/v1/projects/[id]/issues/[issueId]/details', {id: project.id, issueId: '{id}'}, true)}
|
||||
data-attribute="data-id:{id}"
|
||||
>
|
||||
<td data-attribute={`${project.displayid}-{localid}`}></td>
|
||||
@ -95,47 +97,64 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<form data-input={`post:${route('/api/v1/projects/[id]/issues', {id: project.id})}`} data-output="hyp:tbody[data-input]">
|
||||
<form data-input={`post:${route('/api/v1/projects/[id]/issues', {id: project.id})}`} data-output="hyp:tbody[hyp\\:action1]">
|
||||
<Input name="name" label="Ajouter une Issue" placeholder="nom de la tache" />
|
||||
<Input style="display: none" type="textarea" name="name" label="Ajouter une Issue" placeholder="nom de la tache" />
|
||||
<label class="flex gap-2">
|
||||
<input type="checkbox" name="multiple" id="switch-item" />
|
||||
<p>Batch add</p>
|
||||
</label>
|
||||
<Button>Ajouter</Button>
|
||||
</form>
|
||||
<div class="absolute top-0 bg-gray-100 dark:bg-gray-900 h-screen z-20 2xl:w-5/12 xl:w-1/2 md:w-2/3 w-full transition-[right] 2xl:-right-5/12 xl:-right-1/2 md:-right-2/3 -right-full has-[#issue-details>form]:right-0">
|
||||
<div class="flex gap-4 justify-end px-4 pt-4">
|
||||
|
||||
<EllipsisVertical class="cursor-pointer" />
|
||||
<X class="issue-close cursor-pointer" />
|
||||
</div>
|
||||
<div class="fixed overflow-auto top-0 bg-gray-100 dark:bg-gray-900 h-screen z-20 2xl:w-5/12 xl:w-1/2 md:w-2/3 w-full transition-[right] 2xl:-right-5/12 xl:-right-1/2 md:-right-2/3 -right-full has-[#issue-details>form]:right-0">
|
||||
<div id="issue-details"></div>
|
||||
</div>
|
||||
|
||||
<template id="issueTemplate">
|
||||
<form
|
||||
data-trigger="keyup after:250"
|
||||
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}`}
|
||||
data-output="hyp:tbody[data-input]"
|
||||
data-trigger="pointerup keyup after:250"
|
||||
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{issue.id}'}, true)}`}
|
||||
data-output="hyp:tbody[hyp\\:action1]"
|
||||
class="flex flex-col gap-5 px-6 py-2"
|
||||
>
|
||||
<p>
|
||||
<span data-attribute={`${project.displayid}-{localid}`}></span>
|
||||
<span class="text-2xl" data-attribute="name"></span>
|
||||
</p>
|
||||
<Input data-attribute="value:name" name="name" />
|
||||
<Input type="textarea" name="description" data-attribute="description"></Input>
|
||||
<div class="flex gap-4 justify-end pt-4 items-center">
|
||||
<span data-attribute={`${project.displayid}-{issue.localid}`}></span>
|
||||
<div class="flex-grow" data-attribute="issue.name"></div>
|
||||
<label>
|
||||
<input type="checkbox" />
|
||||
<EllipsisVertical class="cursor-pointer" />
|
||||
</label>
|
||||
<X class="issue-close cursor-pointer" hyp:action="clear:#issue-details > *" />
|
||||
</div>
|
||||
<Input data-attribute="value:issue.name" name="name" />
|
||||
<Input type="textarea" name="description" data-attribute="issue.description"></Input>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-4 items-center"><p>State</p><Select name="state" data-attribute="value:state" options={states.map((state) => ({ value: state.id, title: state.name}))} /></div>
|
||||
<div class="flex gap-4 items-center"><p>Priority</p><Select name="priority" data-attribute="value:priority" options={getPriorities().map((priority: Priority) => ({ value: priority, title: getPriorityText(priority)}))} /></div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<p>State</p>
|
||||
<Select
|
||||
name="state"
|
||||
data-attribute="value:issue.state"
|
||||
autocomplete
|
||||
options={states.map((state) => ({ value: state.id, title: state.name}))}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center"><p>Priority</p><Select
|
||||
name="priority"
|
||||
data-attribute="value:issue.priority"
|
||||
value={Priority.NONE}
|
||||
autocomplete
|
||||
options={getPriorities().map((priority: Priority) => ({ value: priority.toString(), title: getPriorityText(priority)}))}
|
||||
/></div>
|
||||
<div class="flex gap-4 items-center"><p>Labels</p><Select
|
||||
data-trigger="change"
|
||||
name="label"
|
||||
multiple
|
||||
name="labels"
|
||||
options={['a', 'b', 'c']}
|
||||
data-output="hyp:[data-id='{id}']"
|
||||
data-attribute="value:labels"
|
||||
multiple
|
||||
data-attribute="value:issue.labels"
|
||||
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]/labels', {id: project.id, issueId: '{id}'}, true)}`}
|
||||
/></div>
|
||||
<ul class="flex gap-2 flex-row">
|
||||
<li data-loop="labels" class="group flex gap-2 bg-slate-700 px-2 py-px rounded-full items-center">
|
||||
<li data-loop="issue.labels" class="group flex gap-2 bg-slate-700 px-2 py-px rounded-full items-center">
|
||||
<span data-attribute="this"></span>
|
||||
<X
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
@ -149,6 +168,15 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
||||
|
||||
<p class="pl-2 text-lg text-center">History</p>
|
||||
<ul class="border-l flex flex-col gap-2">
|
||||
<li data-loop="history" class="py-2 px-4 bg-white dark:bg-gray-800 rounded-xl mb-2 mx-2">
|
||||
<p class="flex justify-between">
|
||||
<span data-attribute="this.user"></span>
|
||||
<span data-attribute="this.created"></span>
|
||||
</p>
|
||||
<p class="italic" data-attribute="this.description"></p>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- <ul class="border-l flex flex-col gap-2">
|
||||
<li class="py-2 px-4 bg-white dark:bg-gray-800 rounded-xl mb-2 mx-2">
|
||||
<p class="flex justify-between">
|
||||
<span>Username</span>
|
||||
@ -157,27 +185,34 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
||||
<p class="italic">changed state from "Todo" to "WIP"</p>
|
||||
</li>
|
||||
|
||||
<li class="py-2 px-4 border-2 border-gray-50 rounded-xl mb-2 mx-2">
|
||||
<li class="py-2 px-4 border-2 bg-white border-gray-50 rounded-xl mb-2 mx-2">
|
||||
<p class="font-bold flex justify-between">
|
||||
<span>Username</span>
|
||||
<span>2024-06-09 14:45:58</span>
|
||||
</p>
|
||||
<p>Il faudrait voir pour changer la valeur de l'élément, cela fait un peu chelou dans l'état actuel...</p>
|
||||
</li>
|
||||
</ul>
|
||||
</ul> -->
|
||||
<Input type="textarea" label="Commentaire" />
|
||||
|
||||
</form>
|
||||
</template>
|
||||
</main>
|
||||
</MainLayout>
|
||||
|
||||
<script>
|
||||
const x = document.querySelector('.issue-close')
|
||||
const details = document.querySelector('#issue-details')
|
||||
x?.addEventListener('click', () => {
|
||||
while (details?.firstElementChild) {
|
||||
details?.firstElementChild.remove()
|
||||
// swap element
|
||||
const item = document.querySelector<HTMLInputElement>('#switch-item')
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="name"]')
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="name"]')
|
||||
item?.addEventListener('click', () => {
|
||||
if (item.checked) {
|
||||
input!.name = ''
|
||||
input!.style.display = 'none'
|
||||
textarea!.style.display = ''
|
||||
} else {
|
||||
input!.name = 'name'
|
||||
textarea!.name = ''
|
||||
input!.style.display = ''
|
||||
textarea!.style.display = 'none'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -62,9 +62,3 @@ console.log(res)
|
||||
<li data-attribute="this"></li>
|
||||
</template>
|
||||
</MainLayout>
|
||||
|
||||
<script>
|
||||
import Hyperion from 'libs/Hyperion'
|
||||
|
||||
Hyperion.setup()
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user