[chore] format ts
Signed-off-by: Anton Nesterov <anton@demiurg.io>
This commit is contained in:
parent
0cecd1243f
commit
60d213f21c
15
README.md
15
README.md
|
@ -5,17 +5,14 @@ Data Accees Layer for SQL databases written in Go.
|
||||||
Mongodb inspired query interface:
|
Mongodb inspired query interface:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const query = Db
|
const query = Db.In("users")
|
||||||
.In("users")
|
|
||||||
.Find({
|
.Find({
|
||||||
fullname: { $glob: "*son" }
|
fullname: { $glob: "*son" },
|
||||||
})
|
})
|
||||||
.Query()
|
.Query();
|
||||||
|
|
||||||
// Result:
|
// Result:
|
||||||
console.log(users)
|
console.log(users)[
|
||||||
[
|
({ id: 25, fullname: "John Menson" }, { id: 76, fullname: "John Johnson" })
|
||||||
{ id: 25, fullname: "John Menson" },
|
];
|
||||||
{ id: 76, fullname: "John Johnson" }
|
|
||||||
]
|
|
||||||
```
|
```
|
304
dal/Builder.ts
304
dal/Builder.ts
|
@ -4,22 +4,22 @@ import { METHODS, encodeRequest, decodeRowsIterator } from "./Protocol";
|
||||||
type Primitive = string | number | boolean | null;
|
type Primitive = string | number | boolean | null;
|
||||||
|
|
||||||
interface Filter extends Record<string, unknown> {
|
interface Filter extends Record<string, unknown> {
|
||||||
$eq?: Primitive;
|
$eq?: Primitive;
|
||||||
$ne?: Primitive;
|
$ne?: Primitive;
|
||||||
$gt?: Primitive;
|
$gt?: Primitive;
|
||||||
$gte?: Primitive;
|
$gte?: Primitive;
|
||||||
$lt?: Primitive;
|
$lt?: Primitive;
|
||||||
$lte?: Primitive;
|
$lte?: Primitive;
|
||||||
$in?: Primitive[];
|
$in?: Primitive[];
|
||||||
$nin?: Primitive[];
|
$nin?: Primitive[];
|
||||||
$like?: string;
|
$like?: string;
|
||||||
$nlike?: string;
|
$nlike?: string;
|
||||||
$glob?: string;
|
$glob?: string;
|
||||||
$between?: [Primitive, Primitive];
|
$between?: [Primitive, Primitive];
|
||||||
$nbetween?: [Primitive, Primitive];
|
$nbetween?: [Primitive, Primitive];
|
||||||
}
|
}
|
||||||
interface FindFilter {
|
interface FindFilter {
|
||||||
[key: string]: Primitive | Filter | Filter[] | undefined;
|
[key: string]: Primitive | Filter | Filter[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type JoinCondition = "inner" | "left" | "cross" | "full outer";
|
type JoinCondition = "inner" | "left" | "cross" | "full outer";
|
||||||
|
@ -32,150 +32,146 @@ type JoinFilter = {
|
||||||
type SortOptions = Record<string, 1 | -1 | "asc" | "desc">;
|
type SortOptions = Record<string, 1 | -1 | "asc" | "desc">;
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
database: string;
|
database: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default class Builder<I extends abstract new (...args: any) => any> {
|
||||||
export default class Builder <
|
private request: Request;
|
||||||
I extends abstract new (...args: any) => any,
|
private url: string;
|
||||||
>{
|
private dtoTemplate: new (...args: any) => any = Object;
|
||||||
private request: Request;
|
private methodCalls: Map<string, unknown[]> = new Map(); // one call per method
|
||||||
private url: string;
|
private headerRow: unknown[] | null = null;
|
||||||
private dtoTemplate: new (...args: any) => any = Object;
|
constructor(opts: Options) {
|
||||||
private methodCalls: Map<string, unknown[]> = new Map(); // one call per method
|
this.request = {
|
||||||
private headerRow: unknown[] | null = null;
|
id: 0,
|
||||||
constructor(opts: Options) {
|
db: opts.database,
|
||||||
this.request = {
|
commands: [],
|
||||||
id: 0,
|
};
|
||||||
db: opts.database,
|
this.url = opts.url;
|
||||||
commands: [],
|
}
|
||||||
};
|
private formatRequest(): void {
|
||||||
this.url = opts.url;
|
this.request.commands = [];
|
||||||
|
METHODS.forEach((method) => {
|
||||||
|
const args = this.methodCalls.get(method);
|
||||||
|
if (!args) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.request.commands.push({ method, args });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private formatRow(data: unknown[]) {
|
||||||
|
if (!this.dtoTemplate) {
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
private formatRequest(): void {
|
const instance = new this.dtoTemplate(data);
|
||||||
this.request.commands = []
|
for (const idx in this.headerRow!) {
|
||||||
METHODS.forEach((method) => {
|
const header = this.headerRow[idx] as string;
|
||||||
const args = this.methodCalls.get(method);
|
if (header in instance) {
|
||||||
if (!args) {
|
instance[header] = data[idx];
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
this.request.commands.push({ method, args });
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
private formatRow(data: unknown[]){
|
return instance;
|
||||||
if (!this.dtoTemplate) {
|
}
|
||||||
return data;
|
In(table: string): Builder<I> {
|
||||||
}
|
this.methodCalls.set("In", [table]);
|
||||||
const instance = new this.dtoTemplate(data);
|
return this;
|
||||||
for (const idx in this.headerRow!) {
|
}
|
||||||
const header = this.headerRow[idx] as string;
|
Find(filter: FindFilter): Builder<I> {
|
||||||
if (header in instance) {
|
this.methodCalls.set("Find", [filter]);
|
||||||
instance[header] = data[idx];
|
return this;
|
||||||
}
|
}
|
||||||
}
|
Select(fields: string[]): Builder<I> {
|
||||||
return instance;
|
this.methodCalls.set("Select", fields);
|
||||||
}
|
return this;
|
||||||
In(table: string): Builder<I> {
|
}
|
||||||
this.methodCalls.set("In", [table]);
|
Fields(fields: string[]): Builder<I> {
|
||||||
return this;
|
this.Select(fields);
|
||||||
}
|
return this;
|
||||||
Find(filter: FindFilter): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Find", [filter]);
|
Join(...joins: JoinFilter[]): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Join", joins);
|
||||||
}
|
return this;
|
||||||
Select(fields: string[]): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Select", fields);
|
Group(fields: string[]): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Group", fields);
|
||||||
}
|
return this;
|
||||||
Fields(fields: string[]): Builder<I> {
|
}
|
||||||
this.Select(fields);
|
Sort(fields: SortOptions): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Sort", [fields]);
|
||||||
}
|
return this;
|
||||||
Join(...joins: JoinFilter[]): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Join", joins);
|
Limit(limit: number): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Limit", [limit]);
|
||||||
}
|
return this;
|
||||||
Group(fields: string[]): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Group", fields);
|
Offset(offset: number): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Offset", [offset]);
|
||||||
}
|
return this;
|
||||||
Sort(fields: SortOptions): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Sort", [fields]);
|
Delete(): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Delete", []);
|
||||||
}
|
return this;
|
||||||
Limit(limit: number): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Limit", [limit]);
|
Insert(data: Record<string, unknown>): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Insert", [data]);
|
||||||
}
|
return this;
|
||||||
Offset(offset: number): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Offset", [offset]);
|
Set(data: Record<string, unknown>): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("Set", [data]);
|
||||||
}
|
return this;
|
||||||
Delete(): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Delete", []);
|
Update(data: Record<string, unknown>): Builder<I> {
|
||||||
return this;
|
this.Set(data);
|
||||||
}
|
return this;
|
||||||
Insert(data: Record<string, unknown>): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Insert", [data]);
|
OnConflict(...fields: string[]): Builder<I> {
|
||||||
return this;
|
this.methodCalls.set("OnConflict", fields);
|
||||||
}
|
return this;
|
||||||
Set(data: Record<string, unknown>): Builder<I> {
|
}
|
||||||
this.methodCalls.set("Set", [data]);
|
DoUpdate(...fields: string[]): Builder<I> {
|
||||||
return this;
|
this.methodCalls.delete("DoNothing");
|
||||||
}
|
this.methodCalls.set("DoUpdate", fields);
|
||||||
Update(data: Record<string, unknown>): Builder<I> {
|
return this;
|
||||||
this.Set(data);
|
}
|
||||||
return this;
|
DoNothing(): Builder<I> {
|
||||||
}
|
this.methodCalls.delete("DoUpdate");
|
||||||
OnConflict(...fields: string[]): Builder<I> {
|
this.methodCalls.set("DoNothing", []);
|
||||||
this.methodCalls.set("OnConflict", fields);
|
return this;
|
||||||
return this;
|
}
|
||||||
}
|
async *Rows<T = InstanceType<I>>(): AsyncGenerator<T> {
|
||||||
DoUpdate(...fields: string[]): Builder<I> {
|
this.formatRequest();
|
||||||
this.methodCalls.delete("DoNothing");
|
const response = await fetch(this.url, {
|
||||||
this.methodCalls.set("DoUpdate", fields);
|
method: "POST",
|
||||||
return this;
|
body: new Blob([encodeRequest(this.request)]),
|
||||||
}
|
headers: {
|
||||||
DoNothing(): Builder<I> {
|
"Content-Type": "application/x-msgpack",
|
||||||
this.methodCalls.delete("DoUpdate");
|
},
|
||||||
this.methodCalls.set("DoNothing", []);
|
});
|
||||||
return this;
|
if (response.status !== 200) {
|
||||||
}
|
throw new Error(await response.text());
|
||||||
async *Rows<T = InstanceType<I>>(): AsyncGenerator<T> {
|
|
||||||
this.formatRequest();
|
|
||||||
const response = await fetch(this.url, {
|
|
||||||
method: "POST",
|
|
||||||
body: new Blob([encodeRequest(this.request)]),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-msgpack",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
const iterator = decodeRowsIterator(response.body!);
|
|
||||||
for await (const row of iterator) {
|
|
||||||
if (this.headerRow === null) {
|
|
||||||
this.headerRow = row.r;
|
|
||||||
await iterator.next();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
yield this.formatRow(row.r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
As<T extends new (...args: any) => any>(template: T): Builder<T> {
|
|
||||||
this.dtoTemplate = template;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
async Query<T = InstanceType<I>>(): Promise<T[]> {
|
|
||||||
const rows = this.Rows();
|
|
||||||
const result = [];
|
|
||||||
for await (const row of rows) {
|
|
||||||
result.push(row);
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iterator = decodeRowsIterator(response.body!);
|
||||||
|
for await (const row of iterator) {
|
||||||
|
if (this.headerRow === null) {
|
||||||
|
this.headerRow = row.r;
|
||||||
|
await iterator.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield this.formatRow(row.r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
As<T extends new (...args: any) => any>(template: T): Builder<T> {
|
||||||
|
this.dtoTemplate = template;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
async Query<T = InstanceType<I>>(): Promise<T[]> {
|
||||||
|
const rows = this.Rows();
|
||||||
|
const result = [];
|
||||||
|
for await (const row of rows) {
|
||||||
|
result.push(row);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,68 +1,73 @@
|
||||||
import { encode, decode } from '@msgpack/msgpack';
|
import { encode, decode } from "@msgpack/msgpack";
|
||||||
|
|
||||||
export interface Method {
|
export interface Method {
|
||||||
method: string;
|
method: string;
|
||||||
args: any;
|
args: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Request {
|
export interface Request {
|
||||||
id: number;
|
id: number;
|
||||||
db: string;
|
db: string;
|
||||||
commands: Method[];
|
commands: Method[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const METHODS = "In|Find|Select|Fields|Join|Group|Sort|Limit|Offset|Delete|Insert|Set|Update|OnConflict|DoUpdate|DoNothing".split("|");
|
export const METHODS =
|
||||||
|
"In|Find|Select|Fields|Join|Group|Sort|Limit|Offset|Delete|Insert|Set|Update|OnConflict|DoUpdate|DoNothing".split(
|
||||||
|
"|",
|
||||||
|
);
|
||||||
|
|
||||||
export function encodeRequest(request: Request): Uint8Array {
|
export function encodeRequest(request: Request): Uint8Array {
|
||||||
return encode(request);
|
return encode(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Row {
|
export interface Row {
|
||||||
r: unknown[];
|
r: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_TAG = [0x81, 0xa1, 0x72];
|
const ROW_TAG = [0x81, 0xa1, 0x72];
|
||||||
|
|
||||||
export function decodeRows(input: Uint8Array): Row[] {
|
export function decodeRows(input: Uint8Array): Row[] {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let buf = [];
|
let buf = [];
|
||||||
while (count < input.length) {
|
while (count < input.length) {
|
||||||
if (input.at(count) != 0x81) {
|
if (input.at(count) != 0x81) {
|
||||||
buf.push(input.at(count));
|
buf.push(input.at(count));
|
||||||
count++;
|
count++;
|
||||||
continue
|
continue;
|
||||||
}
|
|
||||||
const [a, b, c] = ROW_TAG;
|
|
||||||
const [aa, bb, cc] = input.slice(count, count + 4);
|
|
||||||
if (aa == a && bb == b && cc == c) {
|
|
||||||
rows.push([...ROW_TAG, ...buf]);
|
|
||||||
buf = [];
|
|
||||||
count += 3;
|
|
||||||
} else {
|
|
||||||
buf.push(input.at(count));
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rows.push([...ROW_TAG, ...buf]);
|
const [a, b, c] = ROW_TAG;
|
||||||
rows.shift();
|
const [aa, bb, cc] = input.slice(count, count + 4);
|
||||||
return rows.map((row) => decode(new Uint8Array(row as number[]))) as Row[];
|
if (aa == a && bb == b && cc == c) {
|
||||||
|
rows.push([...ROW_TAG, ...buf]);
|
||||||
|
buf = [];
|
||||||
|
count += 3;
|
||||||
|
} else {
|
||||||
|
buf.push(input.at(count));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push([...ROW_TAG, ...buf]);
|
||||||
|
rows.shift();
|
||||||
|
return rows.map((row) => decode(new Uint8Array(row as number[]))) as Row[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function *decodeRowsIterator(stream: ReadableStream<Uint8Array>): AsyncGenerator<Row> {
|
export async function* decodeRowsIterator(
|
||||||
const reader = stream.getReader();
|
stream: ReadableStream<Uint8Array>,
|
||||||
let buf = new Uint8Array();
|
): AsyncGenerator<Row> {
|
||||||
for (;;) {
|
const reader = stream.getReader();
|
||||||
const { value, done } = await reader.read();
|
let buf = new Uint8Array();
|
||||||
if (done) {
|
for (;;) {
|
||||||
break;
|
const { value, done } = await reader.read();
|
||||||
}
|
if (done) {
|
||||||
buf = new Uint8Array([...buf, ...value]);
|
break;
|
||||||
// the server flushes after each row
|
|
||||||
// so we decode "complete" rows
|
|
||||||
const rows = decodeRows(buf);
|
|
||||||
for (const row of rows) {
|
|
||||||
yield row;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
buf = new Uint8Array([...buf, ...value]);
|
||||||
|
// the server flushes after each row
|
||||||
|
// so we decode "complete" rows
|
||||||
|
const rows = decodeRows(buf);
|
||||||
|
for (const row of rows) {
|
||||||
|
yield row;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,61 +1,61 @@
|
||||||
import { test, expect } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
import { DAL } from ".."
|
import { DAL } from "..";
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
database: "test.sqlite",
|
database: "test.sqlite",
|
||||||
url: "http://localhost:8111",
|
url: "http://localhost:8111",
|
||||||
}
|
};
|
||||||
|
|
||||||
class DTO {
|
class DTO {
|
||||||
id: number = 0;
|
id: number = 0;
|
||||||
name: string = "";
|
name: string = "";
|
||||||
data: string = "";
|
data: string = "";
|
||||||
age: number | undefined;
|
age: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Rows iter, no format", async () => {
|
test("Rows iter, no format", async () => {
|
||||||
const dal = new DAL(options);
|
const dal = new DAL(options);
|
||||||
const rows = dal
|
const rows = dal
|
||||||
.In("test t")
|
.In("test t")
|
||||||
.Find({
|
.Find({
|
||||||
id: 1,
|
id: 1,
|
||||||
})
|
})
|
||||||
.Rows<any[]>();
|
.Rows<any[]>();
|
||||||
for await (const row of rows) {
|
for await (const row of rows) {
|
||||||
console.log(row);
|
console.log(row);
|
||||||
expect(row.length).toBe(3);
|
expect(row.length).toBe(3);
|
||||||
}
|
}
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Rows iter, format", async () => {
|
test("Rows iter, format", async () => {
|
||||||
const dal = new DAL(options);
|
const dal = new DAL(options);
|
||||||
const rows = dal
|
const rows = dal
|
||||||
.In("test t")
|
.In("test t")
|
||||||
.Find({
|
.Find({
|
||||||
id: 1,
|
id: 1,
|
||||||
})
|
})
|
||||||
.As(DTO)
|
.As(DTO)
|
||||||
.Rows();
|
.Rows();
|
||||||
for await (const row of rows) {
|
for await (const row of rows) {
|
||||||
console.log(row);
|
console.log(row);
|
||||||
expect(row.id).toBe(1);
|
expect(row.id).toBe(1);
|
||||||
}
|
}
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Query format", async () => {
|
test("Query format", async () => {
|
||||||
const dal = new DAL(options);
|
const dal = new DAL(options);
|
||||||
const rows = await dal
|
const rows = await dal
|
||||||
.In("test t")
|
.In("test t")
|
||||||
.Find({
|
.Find({
|
||||||
id: 1,
|
id: 1,
|
||||||
})
|
})
|
||||||
.As(DTO)
|
.As(DTO)
|
||||||
.Query();
|
.Query();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
expect(row.id).toBeDefined();
|
expect(row.id).toBeDefined();
|
||||||
expect(row.age).toBeUndefined();
|
expect(row.age).toBeUndefined();
|
||||||
}
|
}
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
Binary file not shown.
|
@ -1 +1 @@
|
||||||
export { default as DAL } from './Builder';
|
export { default as DAL } from "./Builder";
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# [wip] DAL
|
# [wip] DAL
|
||||||
NodeJS Client for the [DAL]() Server.
|
|
||||||
|
|
||||||
|
NodeJS Client for the [DAL]() Server.
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
"module": "dal/index.ts",
|
"module": "dal/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest",
|
||||||
|
"prettier": "^3.3.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
@ -13,7 +14,8 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:client": "bun test:*",
|
"test:client": "bun test:*",
|
||||||
"test:dal" : "bun test dal/__test__",
|
"test:dal": "bun test dal/__test__",
|
||||||
"test:serve": "cd dal/__test__/srv && go run main.go"
|
"test:serve": "cd dal/__test__/srv && go run main.go",
|
||||||
|
"fmt": "prettier --write ."
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,17 +2,19 @@
|
||||||
import { encode } from "https://deno.land/x/msgpack@v1.2/mod.ts";
|
import { encode } from "https://deno.land/x/msgpack@v1.2/mod.ts";
|
||||||
|
|
||||||
const Query = {
|
const Query = {
|
||||||
"db": "database.sqlite",
|
db: "database.sqlite",
|
||||||
"commands": [
|
commands: [
|
||||||
{"method": "In", "args": ["data"]},
|
{ method: "In", args: ["data"] },
|
||||||
{
|
{
|
||||||
"method": "Find",
|
method: "Find",
|
||||||
"args": [{
|
args: [
|
||||||
"a": 1,
|
{
|
||||||
"b": {
|
a: 1,
|
||||||
"$gt": 2,
|
b: {
|
||||||
},
|
$gt: 2,
|
||||||
}]
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue