diff --git a/src/decorator/options/ColumnOptions.ts b/src/decorator/options/ColumnOptions.ts index 931042c6e..b06ff4f54 100644 --- a/src/decorator/options/ColumnOptions.ts +++ b/src/decorator/options/ColumnOptions.ts @@ -171,4 +171,13 @@ export interface ColumnOptions extends ColumnCommonOptions { * SRID (Spatial Reference ID (EPSG code)) */ srid?: number; + + /** + * HEX: Per-column planner statistics target. Postgres only. + * Maps to `ALTER TABLE ... ALTER COLUMN ... SET STATISTICS N`. Controls how many + * rows ANALYZE samples for this column's histogram (cluster default is typically 100). + * Use a higher value (e.g. 1000) on hot predicate columns where the cluster default + * undercounts `n_distinct` and causes plan instability. + */ + statisticsTarget?: number; } diff --git a/src/driver/postgres/PostgresQueryRunner.ts b/src/driver/postgres/PostgresQueryRunner.ts index 9009c206c..ceca7250d 100644 --- a/src/driver/postgres/PostgresQueryRunner.ts +++ b/src/driver/postgres/PostgresQueryRunner.ts @@ -424,6 +424,13 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner }); } + // SET STATISTICS can't ride inside CREATE TABLE; emit as follow-up ALTERs. + table.columns + .filter(column => column.statisticsTarget != null) + .forEach(column => { + upQueries.push(new Query(`ALTER TABLE ${this.escapePath(table)} ALTER COLUMN "${column.name}" SET STATISTICS ${column.statisticsTarget}`)); + }); + await this.executeQueries(upQueries, downQueries); } @@ -630,6 +637,10 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner downQueries.push(new Query(`COMMENT ON COLUMN ${this.escapePath(table)}."${column.name}" IS ${this.escapeComment(column.comment)}`)); } + if (column.statisticsTarget != null) { + upQueries.push(new Query(`ALTER TABLE ${this.escapePath(table)} ALTER COLUMN "${column.name}" SET STATISTICS ${column.statisticsTarget}`)); + } + await this.executeQueries(upQueries, downQueries); clonedTable.addColumn(column); @@ -853,6 +864,14 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner downQueries.push(new Query(`COMMENT ON COLUMN ${this.escapePath(table)}."${newColumn.name}" IS ${this.escapeComment(oldColumn.comment)}`)); } + if (oldColumn.statisticsTarget !== newColumn.statisticsTarget) { + // pg attstattarget: -1 = cluster default. map null/undefined to -1 so clearing an override emits the right reset. + const upTarget = newColumn.statisticsTarget == null ? -1 : newColumn.statisticsTarget; + const downTarget = oldColumn.statisticsTarget == null ? -1 : oldColumn.statisticsTarget; + upQueries.push(new Query(`ALTER TABLE ${this.escapePath(table)} ALTER COLUMN "${newColumn.name}" SET STATISTICS ${upTarget}`)); + downQueries.push(new Query(`ALTER TABLE ${this.escapePath(table)} ALTER COLUMN "${oldColumn.name}" SET STATISTICS ${downTarget}`)); + } + if (newColumn.isPrimary !== oldColumn.isPrimary) { const primaryColumns = clonedTable.primaryColumns; @@ -1471,7 +1490,8 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner SELECT columns.*, pg_catalog.col_description(('"' || table_catalog || '"."' || table_schema || '"."' || table_name || '"')::regclass::oid, ordinal_position) AS description, ('"' || "udt_schema" || '"."' || "udt_name" || '"')::"regtype" AS "regtype", - pg_catalog.format_type("col_attr"."atttypid", "col_attr"."atttypmod") AS "format_type" + pg_catalog.format_type("col_attr"."atttypid", "col_attr"."atttypmod") AS "format_type", + "col_attr"."attstattarget" AS "statistics_target" FROM "information_schema"."columns" LEFT JOIN "pg_catalog"."pg_attribute" AS "col_attr" ON "col_attr"."attname" = "columns"."column_name" @@ -1696,6 +1716,10 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner tableColumn.charset = dbColumn["character_set_name"]; if (dbColumn["collation_name"]) tableColumn.collation = dbColumn["collation_name"]; + // pg attstattarget: -1 = cluster default. only carry an explicit override into the model so the diff doesn't fire on every column. + if (dbColumn["statistics_target"] != null && Number(dbColumn["statistics_target"]) >= 0) { + tableColumn.statisticsTarget = Number(dbColumn["statistics_target"]); + } return tableColumn; })); diff --git a/src/metadata/ColumnMetadata.ts b/src/metadata/ColumnMetadata.ts index 86f3d7422..f9dd0751a 100644 --- a/src/metadata/ColumnMetadata.ts +++ b/src/metadata/ColumnMetadata.ts @@ -306,6 +306,12 @@ export class ColumnMetadata { */ srid?: number; + /** + * HEX: Per-column planner statistics target. Postgres only. + * Maps to `ALTER TABLE ... ALTER COLUMN ... SET STATISTICS N`. + */ + statisticsTarget?: number; + // --------------------------------------------------------------------- // Constructor // --------------------------------------------------------------------- @@ -406,6 +412,8 @@ export class ColumnMetadata { this.spatialFeatureType = options.args.options.spatialFeatureType; if (options.args.options.srid !== undefined) this.srid = options.args.options.srid; + if (options.args.options.statisticsTarget !== undefined) + this.statisticsTarget = options.args.options.statisticsTarget; if (this.isTreeLevel) this.type = options.connection.driver.mappedDataTypes.treeLevel; if (this.isCreateDate) { diff --git a/src/schema-builder/options/TableColumnOptions.ts b/src/schema-builder/options/TableColumnOptions.ts index 323b2c448..b33e86d95 100644 --- a/src/schema-builder/options/TableColumnOptions.ts +++ b/src/schema-builder/options/TableColumnOptions.ts @@ -137,4 +137,12 @@ export interface TableColumnOptions { * SRID (Spatial Reference ID (EPSG code)) */ srid?: number; + + /** + * HEX: Per-column planner statistics target. Postgres only. + * Maps to `ALTER TABLE ... ALTER COLUMN ... SET STATISTICS N`, which controls how many + * rows ANALYZE samples for this column's histogram (cluster default is typically 100). + * Use -1 to revert to the cluster default. Stored in `pg_attribute.attstattarget`. + */ + statisticsTarget?: number | null; } diff --git a/src/schema-builder/table/TableColumn.ts b/src/schema-builder/table/TableColumn.ts index d7e342b19..738b45fbe 100644 --- a/src/schema-builder/table/TableColumn.ts +++ b/src/schema-builder/table/TableColumn.ts @@ -140,6 +140,13 @@ export class TableColumn { */ srid?: number; + /** + * HEX: Per-column planner statistics target. Postgres only. + * Maps to `ALTER TABLE ... ALTER COLUMN ... SET STATISTICS N`. Stored in + * `pg_attribute.attstattarget`. -1 means cluster default (the postgres convention). + */ + statisticsTarget?: number | null; + // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- @@ -171,6 +178,7 @@ export class TableColumn { this.generatedType = options.generatedType; this.spatialFeatureType = options.spatialFeatureType; this.srid = options.srid; + this.statisticsTarget = options.statisticsTarget; } } @@ -207,7 +215,8 @@ export class TableColumn { isArray: this.isArray, comment: this.comment, spatialFeatureType: this.spatialFeatureType, - srid: this.srid + srid: this.srid, + statisticsTarget: this.statisticsTarget }); } diff --git a/src/schema-builder/util/TableUtils.ts b/src/schema-builder/util/TableUtils.ts index 32acb40b5..ae3e214ef 100644 --- a/src/schema-builder/util/TableUtils.ts +++ b/src/schema-builder/util/TableUtils.ts @@ -30,7 +30,8 @@ export class TableUtils { enum: columnMetadata.enum ? columnMetadata.enum.map(val => val + "") : columnMetadata.enum, enumName: columnMetadata.enumName, spatialFeatureType: columnMetadata.spatialFeatureType, - srid: columnMetadata.srid + srid: columnMetadata.srid, + statisticsTarget: columnMetadata.statisticsTarget }; } diff --git a/test/functional/columns/statistics-target/columns-statistics-target.ts b/test/functional/columns/statistics-target/columns-statistics-target.ts new file mode 100644 index 000000000..aa4771502 --- /dev/null +++ b/test/functional/columns/statistics-target/columns-statistics-target.ts @@ -0,0 +1,88 @@ +import {expect} from "chai"; +import "reflect-metadata"; +import {Connection} from "../../../../src"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils"; +import {Test} from "./entity/Test"; + +describe("columns > statistics target", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [Test], + // postgres-only feature; other drivers ignore the option. + enabledDrivers: ["postgres"] + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should round-trip statistics target through createTable and loadTables", () => Promise.all(connections.map(async connection => { + const table = (await connection.createQueryRunner().getTable("test"))!; + + expect(table.findColumnByName("a")!.statisticsTarget).to.be.equal(undefined); + expect(table.findColumnByName("b")!.statisticsTarget).to.be.equal(1000); + expect(table.findColumnByName("c")!.statisticsTarget).to.be.equal(5000); + }))); + + it("should emit ALTER ... SET STATISTICS when target changes on changeColumn", () => Promise.all(connections.map(async connection => { + const queryRunner = connection.createQueryRunner(); + const table = (await queryRunner.getTable("test"))!; + const oldColumn = table.findColumnByName("b")!; + const newColumn = oldColumn.clone(); + newColumn.statisticsTarget = 2500; + + queryRunner.enableSqlMemory(); + await queryRunner.changeColumn(table, oldColumn, newColumn); + const memory = queryRunner.getMemorySql(); + queryRunner.disableSqlMemory(); + + const ups = memory.upQueries.map(q => q.query); + const downs = memory.downQueries.map(q => q.query); + + expect(ups.some(sql => /ALTER TABLE .*"test".* ALTER COLUMN "b" SET STATISTICS 2500/.test(sql))).to.be.true; + expect(downs.some(sql => /ALTER TABLE .*"test".* ALTER COLUMN "b" SET STATISTICS 1000/.test(sql))).to.be.true; + + await queryRunner.release(); + }))); + + it("should emit SET STATISTICS -1 when clearing the target", () => Promise.all(connections.map(async connection => { + const queryRunner = connection.createQueryRunner(); + const table = (await queryRunner.getTable("test"))!; + const oldColumn = table.findColumnByName("c")!; + const newColumn = oldColumn.clone(); + newColumn.statisticsTarget = undefined; + + queryRunner.enableSqlMemory(); + await queryRunner.changeColumn(table, oldColumn, newColumn); + const memory = queryRunner.getMemorySql(); + queryRunner.disableSqlMemory(); + + const ups = memory.upQueries.map(q => q.query); + const downs = memory.downQueries.map(q => q.query); + + expect(ups.some(sql => /ALTER TABLE .*"test".* ALTER COLUMN "c" SET STATISTICS -1/.test(sql))).to.be.true; + expect(downs.some(sql => /ALTER TABLE .*"test".* ALTER COLUMN "c" SET STATISTICS 5000/.test(sql))).to.be.true; + + await queryRunner.release(); + }))); + + it("should emit SET STATISTICS on addColumn when the new column has a target", () => Promise.all(connections.map(async connection => { + const queryRunner = connection.createQueryRunner(); + const table = (await queryRunner.getTable("test"))!; + const newColumn = table.findColumnByName("b")!.clone(); + newColumn.name = "d"; + newColumn.statisticsTarget = 750; + + queryRunner.enableSqlMemory(); + await queryRunner.addColumn(table, newColumn); + const memory = queryRunner.getMemorySql(); + queryRunner.disableSqlMemory(); + + const ups = memory.upQueries.map(q => q.query); + + expect(ups.some(sql => /ALTER TABLE .*"test".* ADD "d" /.test(sql))).to.be.true; + expect(ups.some(sql => /ALTER TABLE .*"test".* ALTER COLUMN "d" SET STATISTICS 750/.test(sql))).to.be.true; + + await queryRunner.release(); + }))); + +}); diff --git a/test/functional/columns/statistics-target/entity/Test.ts b/test/functional/columns/statistics-target/entity/Test.ts new file mode 100644 index 000000000..b7cf4b2f8 --- /dev/null +++ b/test/functional/columns/statistics-target/entity/Test.ts @@ -0,0 +1,22 @@ +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {Column} from "../../../../../src/decorator/columns/Column"; +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; + +@Entity() +export class Test { + + @PrimaryGeneratedColumn() + id: number; + + // Default cluster target (no override). + @Column() + a: string; + + // Explicit override. + @Column({ statisticsTarget: 1000 }) + b: string; + + // Larger override for a hot predicate column. + @Column({ statisticsTarget: 5000 }) + c: string; +}