diff --git a/build/Dockerfile b/build/Dockerfile index 9e12f1d405..dcd783edeb 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -77,6 +77,8 @@ COPY --from=builder /app/dist/ /usr/share/nginx/html ENV PORT=80 # The url to the CouchDB database ENV COUCHDB_URL="http://localhost" +# The url to the query backend, see https://github.com/Aam-Digital/query-backend +ENV QUERY_URL="http://localhost:3000" # The url to a nominatim instance, see https://nominatim.org/ ENV NOMINATIM_URL="https://nominatim.openstreetmap.org" @@ -91,5 +93,5 @@ ENV CSP="default-src 'self' 'unsafe-eval' data: https://*.tile.openstreetmap.org ### 'unsafe-eval' required for pouchdb https://github.com/pouchdb/pouchdb/issues/7853#issuecomment-535020600 # variables are inserted into the nginx config -CMD envsubst '$$PORT $$COUCHDB_URL $$NOMINATIM_URL $$CSP $$CSP_REPORT_URI' < /etc/nginx/templates/default.conf > /etc/nginx/conf.d/default.conf &&\ +CMD envsubst '$$PORT $$COUCHDB_URL $$QUERY_URL $$NOMINATIM_URL $$CSP $$CSP_REPORT_URI' < /etc/nginx/templates/default.conf > /etc/nginx/conf.d/default.conf &&\ nginx -g 'daemon off;' diff --git a/build/default.conf b/build/default.conf index 7f1d0c835d..53f3d6c194 100644 --- a/build/default.conf +++ b/build/default.conf @@ -49,6 +49,18 @@ server { proxy_set_header X-Forwarded-Ssl on; } + # The proxy path should be the same as in SqlReportService.QUERY_PROXY + location ^~ /query { + rewrite /query/(.*) /$1 break; + proxy_pass ${QUERY_URL}; + proxy_redirect off; + proxy_buffering off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Ssl on; + } + + # The proxy path should be the same as in GeoService.remoteUrl location ^~ /nominatim { rewrite /nominatim/(.*) /$1 break; proxy_pass ${NOMINATIM_URL}; diff --git a/proxy.conf.json b/proxy.conf.json index ed04c094ea..39580bf0ce 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -8,6 +8,15 @@ "/db": "" } }, + "/query": { + "target": "http://localhost:3000", + "secure": true, + "logLevel": "debug", + "changeOrigin": true, + "pathRewrite": { + "/query": "" + } + }, "/nominatim": { "target": "https://nominatim.openstreetmap.org", "secure": true, diff --git a/src/app/core/entity-list/entity-list/entity-list.component.spec.ts b/src/app/core/entity-list/entity-list/entity-list.component.spec.ts index 3fd3833bde..6dc89d8f14 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.spec.ts +++ b/src/app/core/entity-list/entity-list/entity-list.component.spec.ts @@ -135,6 +135,8 @@ describe("EntityListComponent", () => { it("should set the clicked column group", async () => { createComponent(); + // Test only works in desktop mode + component.isDesktop = true; await initComponentInputs(); expect(component.selectedColumnGroupIndex).toBe(1); diff --git a/src/app/features/reporting/demo-report-config-generator.service.ts b/src/app/features/reporting/demo-report-config-generator.service.ts index e5879b5f8f..4d525d851c 100644 --- a/src/app/features/reporting/demo-report-config-generator.service.ts +++ b/src/app/features/reporting/demo-report-config-generator.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; import { DemoDataGenerator } from "../../core/demo-data/demo-data-generator"; -import { ReportConfig } from "./report-config"; +import { ReportEntity } from "./report-config"; import { Child } from "../../child-dev-project/children/model/child"; import { School } from "../../child-dev-project/schools/model/school"; import { ChildSchoolRelation } from "../../child-dev-project/children/model/childSchoolRelation"; @@ -8,7 +8,7 @@ import { EventNote } from "../../child-dev-project/attendance/model/event-note"; import { EducationalMaterial } from "../../child-dev-project/children/educational-material/model/educational-material"; @Injectable() -export class DemoReportConfigGeneratorService extends DemoDataGenerator { +export class DemoReportConfigGeneratorService extends DemoDataGenerator { static provider() { return [ { @@ -18,12 +18,14 @@ export class DemoReportConfigGeneratorService extends DemoDataGenerator ReportConfig.create(report)); + protected generateEntities(): ReportEntity[] { + return demoReports.map((report) => + Object.assign(new ReportEntity(), report), + ); } } -const demoReports: Partial[] = [ +const demoReports: Partial[] = [ { title: $localize`:Name of a report:Basic Report`, aggregationDefinitions: [ diff --git a/src/app/features/reporting/report-config.ts b/src/app/features/reporting/report-config.ts index a3ad9b3411..d3bd051261 100644 --- a/src/app/features/reporting/report-config.ts +++ b/src/app/features/reporting/report-config.ts @@ -1,4 +1,4 @@ -import { Entity } from "../../core/entity/model/entity"; +import { Entity, EntityConstructor } from "../../core/entity/model/entity"; import { DatabaseEntity } from "../../core/entity/database-entity.decorator"; import { Aggregation } from "./data-aggregation.service"; import { ExportColumnConfig } from "../../core/export/data-transformation-service/export-column-config"; @@ -7,24 +7,62 @@ import { DatabaseField } from "../../core/entity/database-field.decorator"; /** * A report can be accessed by users to generate aggregated statistics or customized exports calculated from available data. * "read" permission for a ReportConfig entity is also used to control which users can generate the report's results. + * + * This is the class which is saved to the database. + * However, when using this in code, use the {@link ReportEntity} instead which provides better type safety. */ @DatabaseEntity("ReportConfig") -export class ReportConfig extends Entity { - static create(data: Partial) { - return Object.assign(new ReportConfig(), data); - } - +class ReportConfig extends Entity { /** human-readable title of the report */ @DatabaseField() title: string; /** - * (optional) mode whether the aggregation definitions are of type {@interface Aggregation} or {@interface ExportColumnConfig} + * (optional) mode of export. + * The {@link ReportEntity} holds the restriction on valid report modes. * Default is "reporting" */ - @DatabaseField() mode?: "reporting" | "exporting"; + @DatabaseField() mode?: string; /** the definitions to calculate the report's aggregations */ - @DatabaseField() aggregationDefinitions?: - | Aggregation[] - | ExportColumnConfig[] = []; + @DatabaseField() aggregationDefinitions: any[] = []; +} + +/** + * Union type to enable type safety for report configs. + * Use this instead of the {@class ReportConfig} + */ +export type ReportEntity = AggregationReport | ExportingReport | SqlReport; +/** + * This allows the `ReportEntity` to also be used as a constructor or in the `EntityMapper` + */ +export const ReportEntity = ReportConfig as EntityConstructor; + +/** + * Reports handles by the {@class DataAggregationService} + */ +export interface AggregationReport extends ReportConfig { + mode: "reporting"; + aggregationDefinitions: Aggregation[]; +} + +/** + * Reports handles by the {@class DataTransformationService} + */ +export interface ExportingReport extends ReportConfig { + /** + * If no mode is set, it will default to 'exporting' + */ + mode?: "exporting"; + aggregationDefinitions: ExportColumnConfig[]; +} + +/** + * Reports handles by the {@class SqlReportService} + */ +export interface SqlReport extends ReportConfig { + mode: "sql"; + /** + * Array of valid SQL SELECT statements + */ + aggregationDefinitions: string[]; } diff --git a/src/app/features/reporting/reporting/reporting.component.html b/src/app/features/reporting/reporting/reporting.component.html index 291c0d1941..437d211b90 100644 --- a/src/app/features/reporting/reporting/reporting.component.html +++ b/src/app/features/reporting/reporting/reporting.component.html @@ -21,6 +21,6 @@ [rows]="data" > diff --git a/src/app/features/reporting/reporting/reporting.component.spec.ts b/src/app/features/reporting/reporting/reporting.component.spec.ts index 6285a20ca9..f9add22d06 100644 --- a/src/app/features/reporting/reporting/reporting.component.spec.ts +++ b/src/app/features/reporting/reporting/reporting.component.spec.ts @@ -19,25 +19,17 @@ import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testi import { DataTransformationService } from "../../../core/export/data-transformation-service/data-transformation.service"; import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; import { mockEntityMapper } from "../../../core/entity/entity-mapper/mock-entity-mapper-service"; -import { ReportConfig } from "../report-config"; +import { ReportEntity, SqlReport } from "../report-config"; +import { SqlReportService } from "../sql-report/sql-report.service"; describe("ReportingComponent", () => { let component: ReportingComponent; let fixture: ComponentFixture; let mockReportingService: jasmine.SpyObj; let mockDataTransformationService: jasmine.SpyObj; + let mockSqlReportService: jasmine.SpyObj; - const testReport: ReportConfig = ReportConfig.create({ - title: "test report", - aggregationDefinitions: [ - { - query: "some query", - label: "some label", - groupBy: ["some", "values"], - aggregations: [], - }, - ], - }); + const testReport = new ReportEntity(); beforeEach(async () => { mockReportingService = jasmine.createSpyObj(["calculateReport"]); @@ -45,6 +37,7 @@ describe("ReportingComponent", () => { "queryAndTransformData", ]); mockReportingService.calculateReport.and.resolveTo([]); + mockSqlReportService = jasmine.createSpyObj(["query"]); await TestBed.configureTestingModule({ imports: [ ReportingComponent, @@ -59,6 +52,7 @@ describe("ReportingComponent", () => { provide: DataTransformationService, useValue: mockDataTransformationService, }, + { provide: SqlReportService, useValue: mockSqlReportService }, { provide: EntityMapperService, useValue: mockEntityMapper() }, ], }).compileComponents(); @@ -194,18 +188,16 @@ describe("ReportingComponent", () => { ]); })); - it("should use the export service when aggregation has mode 'exporting'", async () => { + it("should use the export service when report has mode 'exporting'", async () => { const data = [ { First: 1, Second: 2 }, { First: 3, Second: 4 }, ]; mockDataTransformationService.queryAndTransformData.and.resolveTo(data); + const report = new ReportEntity(); + report.mode = "exporting"; - await component.calculateResults( - ReportConfig.create({ mode: "exporting" }), - new Date(), - new Date(), - ); + await component.calculateResults(report, new Date(), new Date()); expect( mockDataTransformationService.queryAndTransformData, @@ -213,4 +205,22 @@ describe("ReportingComponent", () => { expect(component.data).toEqual(data); expect(component.mode).toBe("exporting"); }); + + it("should use the sql report service when report has mode 'sql'", async () => { + const report = new ReportEntity() as SqlReport; + report.mode = "sql"; + + await component.calculateResults( + report, + new Date("2023-01-01"), + new Date("2023-12-31"), + ); + + expect(mockSqlReportService.query).toHaveBeenCalledWith( + report, + new Date("2023-01-01"), + // Next day (to date is exclusive + new Date("2024-01-01"), + ); + }); }); diff --git a/src/app/features/reporting/reporting/reporting.component.ts b/src/app/features/reporting/reporting/reporting.component.ts index 1e9dead572..a7060e19e6 100644 --- a/src/app/features/reporting/reporting/reporting.component.ts +++ b/src/app/features/reporting/reporting/reporting.component.ts @@ -1,14 +1,10 @@ import { Component } from "@angular/core"; -import { - Aggregation, - DataAggregationService, -} from "../data-aggregation.service"; +import { DataAggregationService } from "../data-aggregation.service"; import { getGroupingInformationString, GroupByDescription, } from "../report-row"; import moment from "moment"; -import { ExportColumnConfig } from "../../../core/export/data-transformation-service/export-column-config"; import { NgIf } from "@angular/common"; import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; import { SelectReportComponent } from "./select-report/select-report.component"; @@ -16,7 +12,8 @@ import { ReportRowComponent } from "./report-row/report-row.component"; import { ObjectTableComponent } from "./object-table/object-table.component"; import { DataTransformationService } from "../../../core/export/data-transformation-service/data-transformation.service"; import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; -import { ReportConfig } from "../report-config"; +import { ReportEntity } from "../report-config"; +import { SqlReportService } from "../sql-report/sql-report.service"; import { RouteTarget } from "../../../route-target"; @RouteTarget("Reporting") @@ -34,8 +31,8 @@ import { RouteTarget } from "../../../route-target"; standalone: true, }) export class ReportingComponent { - reports: ReportConfig[]; - mode: "exporting" | "reporting" = "exporting"; + reports: ReportEntity[]; + mode: ReportEntity["mode"]; loading: boolean; data: any[]; @@ -44,15 +41,16 @@ export class ReportingComponent { constructor( private dataAggregationService: DataAggregationService, private dataTransformationService: DataTransformationService, + private sqlReportService: SqlReportService, private entityMapper: EntityMapperService, ) { - this.entityMapper - .loadType(ReportConfig) - .then((res) => (this.reports = res)); + this.entityMapper.loadType(ReportEntity).then((res) => { + this.reports = res.sort((a, b) => a.title.localeCompare(b.title)); + }); } async calculateResults( - selectedReport: ReportConfig, + selectedReport: ReportEntity, fromDate: Date, toDate: Date, ) { @@ -64,50 +62,35 @@ export class ReportingComponent { // Add one day because to date is exclusive const dayAfterToDate = moment(toDate).add(1, "day").toDate(); - - if (selectedReport.mode === "exporting") { - await this.createExport( - selectedReport.aggregationDefinitions as ExportColumnConfig[], - fromDate, - dayAfterToDate, - ); - } else { - await this.createReport( - selectedReport.aggregationDefinitions as Aggregation[], - fromDate, - dayAfterToDate, - ); - } - - this.loading = false; - } - - private async createExport( - exportConfig: ExportColumnConfig[], - fromDate: Date, - toDate: Date, - ) { - this.data = await this.dataTransformationService.queryAndTransformData( - exportConfig, + this.data = await this.getReportResults( + selectedReport, fromDate, - toDate, + dayAfterToDate, ); - this.exportableData = this.data; - this.mode = "exporting"; + this.mode = selectedReport.mode ?? "reporting"; + this.exportableData = + this.mode === "reporting" ? this.flattenReportRows() : this.data; + this.loading = false; } - private async createReport( - aggregationDefinitions: Aggregation[], - fromDate: Date, - toDate: Date, - ) { - this.data = await this.dataAggregationService.calculateReport( - aggregationDefinitions, - fromDate, - toDate, - ); - this.exportableData = this.flattenReportRows(); - this.mode = "reporting"; + private getReportResults(report: ReportEntity, from: Date, to: Date) { + switch (report.mode) { + case "exporting": + return this.dataTransformationService.queryAndTransformData( + report.aggregationDefinitions, + from, + to, + ); + case "sql": + // TODO check/ensure "to" date is also exclusive + return this.sqlReportService.query(report, from, to); + default: + return this.dataAggregationService.calculateReport( + report.aggregationDefinitions, + from, + to, + ); + } } private flattenReportRows( diff --git a/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts b/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts index f46ad8d547..6f126a5c73 100644 --- a/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts +++ b/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { SelectReportComponent } from "./select-report.component"; -import { ReportConfig } from "../../report-config"; +import { ReportEntity } from "../../report-config"; describe("SelectReportComponent", () => { let component: SelectReportComponent; @@ -24,7 +24,7 @@ describe("SelectReportComponent", () => { }); it("should select the first report if only one exists", () => { - const report = ReportConfig.create({ title: "someReport" }); + const report = new ReportEntity(); component.reports = [report]; component.ngOnChanges({ reports: undefined }); diff --git a/src/app/features/reporting/reporting/select-report/select-report.component.ts b/src/app/features/reporting/reporting/select-report/select-report.component.ts index 160deb187f..23522a8aeb 100644 --- a/src/app/features/reporting/reporting/select-report/select-report.component.ts +++ b/src/app/features/reporting/reporting/select-report/select-report.component.ts @@ -17,7 +17,7 @@ import { ExportDataDirective } from "../../../../core/export/export-data-directi import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { MatProgressBarModule } from "@angular/material/progress-bar"; import { MatTooltipModule } from "@angular/material/tooltip"; -import { ReportConfig } from "../../report-config"; +import { ReportEntity } from "../../report-config"; @Component({ selector: "app-select-report", @@ -40,12 +40,12 @@ import { ReportConfig } from "../../report-config"; standalone: true, }) export class SelectReportComponent implements OnChanges { - @Input() reports: ReportConfig[]; + @Input() reports: ReportEntity[]; @Input() loading: boolean; @Input() exportableData: any; @Output() calculateClick = new EventEmitter(); - selectedReport: ReportConfig; + selectedReport: ReportEntity; fromDate: Date; toDate: Date; @@ -59,7 +59,7 @@ export class SelectReportComponent implements OnChanges { } interface CalculateReportOptions { - report: ReportConfig; + report: ReportEntity; from: Date; to: Date; } diff --git a/src/app/features/reporting/sql-report/sql-report.service.spec.ts b/src/app/features/reporting/sql-report/sql-report.service.spec.ts new file mode 100644 index 0000000000..dc1b041154 --- /dev/null +++ b/src/app/features/reporting/sql-report/sql-report.service.spec.ts @@ -0,0 +1,129 @@ +import { TestBed } from "@angular/core/testing"; + +import { SqlReportService } from "./sql-report.service"; +import { Entity } from "../../../core/entity/model/entity"; +import { DatabaseField } from "../../../core/entity/database-field.decorator"; +import { + DatabaseEntity, + entityRegistry, + EntityRegistry, +} from "../../../core/entity/database-entity.decorator"; +import { HttpClient } from "@angular/common/http"; +import { of } from "rxjs"; +import { ReportEntity, SqlReport } from "../report-config"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { mockEntityMapper } from "../../../core/entity/entity-mapper/mock-entity-mapper-service"; +import { SqsSchema } from "./sqs-schema"; +import moment from "moment"; + +describe("SqlReportService", () => { + let service: SqlReportService; + let mockEntities: EntityRegistry; + let mockHttpClient: jasmine.SpyObj; + + beforeEach(() => { + entityRegistry.allowDuplicates(); + mockEntities = new EntityRegistry(); + mockHttpClient = jasmine.createSpyObj(["post"]); + mockHttpClient.post.and.returnValue(of(undefined)); + TestBed.configureTestingModule({ + providers: [ + { provide: EntityRegistry, useValue: mockEntities }, + { provide: HttpClient, useValue: mockHttpClient }, + { provide: EntityMapperService, useValue: mockEntityMapper() }, + ], + }); + service = TestBed.inject(SqlReportService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should query the external service with the provided report data", async () => { + const mockResult = [{ some: "data" }]; + mockHttpClient.post.and.returnValue(of(mockResult)); + const report = new ReportEntity() as SqlReport; + report.mode = "sql"; + + const result = await service.query( + report, + moment("2023-01-01").toDate(), + moment("2024-01-01").toDate(), + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + `${SqlReportService.QUERY_PROXY}/report/app/${report.getId(true)}`, + { + from: "2023-01-01", + to: "2024-01-01", + }, + ); + expect(result).toEqual(mockResult); + }); + + it("should generate a valid schema including all properties", () => { + @DatabaseEntity("SchemaTest") + class SchemaTest extends Entity { + @DatabaseField() numberProp: number; + @DatabaseField() stringProp: string; + @DatabaseField({ dataType: "entity" }) entityProp: string; + @DatabaseField({ innerDataType: "configurable-enum" }) + arrayEnum: string[]; + @DatabaseField() booleanProp: boolean; + } + mockEntities.add(SchemaTest.ENTITY_TYPE, SchemaTest); + + const schema = service.generateSchema(); + + expect(schema.sql).toEqual({ + tables: { + SchemaTest: { + _id: "TEXT", + created: "TEXT", + updated: "TEXT", + inactive: "INTEGER", + anonymized: "INTEGER", + numberProp: "INTEGER", // TODO distinguish REAL and INT? SQLITE anyways has dynamic typing https://sqlite.org/datatype3.html + stringProp: "TEXT", + entityProp: "TEXT", + arrayEnum: "TEXT", + booleanProp: "INTEGER", // TODO check that this works with SQS + }, + }, + options: { + table_name: { + operation: "prefix", + field: "_id", + separator: ":", + }, + }, + }); + }); + + it("should update the schema when querying the service", async () => { + @DatabaseEntity("SchemaTest") + class SchemaTest extends Entity {} + mockEntities.add(SchemaTest.ENTITY_TYPE, SchemaTest); + const entityMapper = TestBed.inject(EntityMapperService); + const saveSpy = spyOn(entityMapper, "save").and.callThrough(); + const report = new ReportEntity() as SqlReport; + report.mode = "sql"; + + await service.query(report, new Date(), new Date()); + expect(saveSpy).toHaveBeenCalledWith(jasmine.any(SqsSchema)); + + // SqsSchema exists and entity schema hasn't changed + saveSpy.calls.reset(); + await service.query(report, new Date(), new Date()); + expect(saveSpy).not.toHaveBeenCalled(); + + // SqsSchema exists and entity schema changed + saveSpy.calls.reset(); + SchemaTest.schema.set("test", { dataType: "string" }); + await service.query(report, new Date(), new Date()); + expect(saveSpy).toHaveBeenCalledWith(jasmine.any(SqsSchema)); + + SchemaTest.schema.delete("test"); + }); +}); diff --git a/src/app/features/reporting/sql-report/sql-report.service.ts b/src/app/features/reporting/sql-report/sql-report.service.ts new file mode 100644 index 0000000000..18d8b9040c --- /dev/null +++ b/src/app/features/reporting/sql-report/sql-report.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from "@angular/core"; +import { SqlTables, SqlType, SqsSchema } from "./sqs-schema"; +import { EntityRegistry } from "../../../core/entity/database-entity.decorator"; +import { EntitySchemaField } from "../../../core/entity/schema/entity-schema-field"; +import { NumberDatatype } from "../../../core/basic-datatypes/number/number.datatype"; +import { BooleanDatatype } from "../../../core/basic-datatypes/boolean/boolean.datatype"; +import { SqlReport } from "../report-config"; +import { HttpClient } from "@angular/common/http"; +import moment from "moment"; +import { firstValueFrom } from "rxjs"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { isEqual } from "lodash-es"; + +/** + * Service that handles management of necessary SQS configurations + */ +@Injectable({ + providedIn: "root", +}) +export class SqlReportService { + static QUERY_PROXY = "/query"; + constructor( + private entities: EntityRegistry, + private http: HttpClient, + private entityMapper: EntityMapperService, + ) {} + + /** + * Get the combines results of the SQL statements in the report + * @param report + * @param from + * @param to + */ + async query(report: SqlReport, from: Date, to: Date) { + await this.updateSchemaIfNecessary(); + return firstValueFrom( + this.http.post( + `${SqlReportService.QUERY_PROXY}/report/app/${report.getId(true)}`, + { + from: moment(from).format("YYYY-MM-DD"), + to: moment(to).format("YYYY-MM-DD"), + }, + ), + ); + } + + /** + * Update SQS schema if entities have changed + * @private + */ + private async updateSchemaIfNecessary() { + const existing = await this.entityMapper + .load(SqsSchema, SqsSchema.SQS_SCHEMA_ID) + .catch(() => new SqsSchema()); + + const newSchema = this.generateSchema(); + if (isEqual(newSchema.sql, existing.sql)) { + return; + } + + existing.sql = newSchema.sql; + await this.entityMapper.save(existing); + } + + /** + * Create a valid SQS schema object for all registered entities + */ + generateSchema(): SqsSchema { + const tables: SqlTables = {}; + for (const [name, ctr] of this.entities.entries()) { + tables[name] = {}; + for (const [attr, attrSchema] of ctr.schema) { + if (attr === "_rev") { + // skip internal property + continue; + } + tables[name][attr] = this.getSqlType(attrSchema); + } + } + return SqsSchema.create(tables); + } + + private getSqlType(schema: EntitySchemaField): SqlType { + switch (schema.dataType) { + case NumberDatatype.dataType: + case BooleanDatatype.dataType: + return "INTEGER"; + default: + return "TEXT"; + } + } +} diff --git a/src/app/features/reporting/sql-report/sqs-schema.ts b/src/app/features/reporting/sql-report/sqs-schema.ts new file mode 100644 index 0000000000..0c92b25378 --- /dev/null +++ b/src/app/features/reporting/sql-report/sqs-schema.ts @@ -0,0 +1,57 @@ +import { Entity } from "../../../core/entity/model/entity"; +import { DatabaseEntity } from "../../../core/entity/database-entity.decorator"; +import { DatabaseField } from "../../../core/entity/database-field.decorator"; + +/** + * SQS schema object. + * For more information, see the SQS docs. + */ +@DatabaseEntity("_design/sqlite") +export class SqsSchema extends Entity { + static SQS_SCHEMA_ID = "config"; + static create(tables: SqlTables) { + const schema = new SqsSchema(); + schema.sql = { + tables, + options: { + table_name: { + operation: "prefix", + field: "_id", + separator: ":", + }, + }, + }; + return schema; + } + constructor() { + super(SqsSchema.SQS_SCHEMA_ID); + } + + @DatabaseField() language: "sqlite" = "sqlite"; + @DatabaseField() sql: { + // SQL table definitions + tables: SqlTables; + // Optional SQL indices + indexes?: string[]; + // Further options + options?: SqlOptions; + }; +} + +export type SqlTables = { + // Name of the entity + [table: string]: { + // Name of the entity attribute and the type of it + [column: string]: SqlType | { field: string; type: SqlType }; + }; +}; + +export type SqlType = "TEXT" | "INTEGER" | "REAL"; + +export type SqlOptions = { + table_name: { + operation: "prefix"; + field: string; + separator: string; + }; +};