Spatial support for PostgreSQL using PostGIS

This includes support for both geometry and geography columns as well as
GiST indices (used when passing the `spatial: true`). Client
representations use GeoJSON (existing MySQL and MS SQL drivers use WKT
(well-known text)) for compatibility with geospatial libraries such as
Turf, JSTS, etc.
This commit is contained in:
Seth Fitzsimmons 2018-06-27 21:54:24 -07:00
parent 51b2a63d91
commit 1e20c6b04a
9 changed files with 178 additions and 11 deletions

View File

@ -39,7 +39,7 @@ services:
# postgres
postgres:
image: "postgres:9.6.1"
image: "mdillon/postgis:9.6"
container_name: "typeorm-postgres"
ports:
- "5432:5432"

View File

@ -10,7 +10,7 @@ export interface IndexOptions {
/**
* The SPATIAL modifier indexes the entire column and does not allow indexed columns to contain NULL values.
* Works only in MySQL.
* Works only in MySQL and PostgreSQL.
*/
spatial?: boolean;

View File

@ -145,13 +145,18 @@ export class PostgresDriver implements Driver {
"numrange",
"tsrange",
"tstzrange",
"daterange"
"daterange",
"geometry",
"geography"
];
/**
* Gets list of spatial column data types.
*/
spatialTypes: ColumnType[] = [];
spatialTypes: ColumnType[] = [
"geometry",
"geography"
];
/**
* Gets list of column data types that support length by a driver.
@ -283,7 +288,10 @@ export class PostgresDriver implements Driver {
const hasHstoreColumns = this.connection.entityMetadatas.some(metadata => {
return metadata.columns.filter(column => column.type === "hstore").length > 0;
});
if (hasUuidColumns || hasCitextColumns || hasHstoreColumns) {
const hasGeometryColumns = this.connection.entityMetadatas.some(metadata => {
return metadata.columns.filter(column => this.spatialTypes.indexOf(column.type) >= 0).length > 0;
});
if (hasUuidColumns || hasCitextColumns || hasHstoreColumns || hasGeometryColumns) {
await Promise.all([this.master, ...this.slaves].map(pool => {
return new Promise((ok, fail) => {
pool.connect(async (err: any, connection: any, release: Function) => {
@ -307,6 +315,12 @@ export class PostgresDriver implements Driver {
} catch (_) {
logger.log("warn", "At least one of the entities has hstore column, but the 'hstore' extension cannot be installed automatically. Please install it manually using superuser rights");
}
if (hasGeometryColumns)
try {
await this.executeQuery(connection, `CREATE EXTENSION IF NOT EXISTS "postgis"`);
} catch (_) {
logger.log("warn", "At least one of the entities has a geometry column, but the 'postgis' extension cannot be installed automatically. Please install it manually using superuser rights");
}
release();
ok();
});
@ -370,7 +384,7 @@ export class PostgresDriver implements Driver {
|| columnMetadata.type === "timestamp without time zone") {
return DateUtils.mixedDateToDate(value);
} else if (columnMetadata.type === "json" || columnMetadata.type === "jsonb") {
} else if (this.spatialTypes.concat(["json", "jsonb"]).indexOf(columnMetadata.type) >= 0) {
return JSON.stringify(value);
} else if (columnMetadata.type === "hstore") {

View File

@ -1182,7 +1182,9 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner
await this.startTransaction();
try {
const selectDropsQuery = `SELECT 'DROP TABLE IF EXISTS "' || schemaname || '"."' || tablename || '" CASCADE;' as "query" FROM "pg_tables" WHERE "schemaname" IN (${schemaNamesString})`;
// ignore spatial_ref_sys; it's a special table supporting PostGIS
// TODO generalize this as this.driver.ignoreTables
const selectDropsQuery = `SELECT 'DROP TABLE IF EXISTS "' || schemaname || '"."' || tablename || '" CASCADE;' as "query" FROM "pg_tables" WHERE "schemaname" IN (${schemaNamesString}) AND tablename NOT IN ('spatial_ref_sys')`;
const dropQueries: ObjectLiteral[] = await this.query(selectDropsQuery);
await Promise.all(dropQueries.map(q => this.query(q["query"])));
await this.dropEnumTypes(schemaNamesString);
@ -1242,12 +1244,14 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner
`WHERE "t"."relkind" = 'r' AND (${constraintsCondition})`;
const indicesSql = `SELECT "ns"."nspname" AS "table_schema", "t"."relname" AS "table_name", "i"."relname" AS "constraint_name", "a"."attname" AS "column_name", ` +
`CASE "ix"."indisunique" WHEN 't' THEN 'TRUE' ELSE'FALSE' END AS "is_unique", pg_get_expr("ix"."indpred", "ix"."indrelid") AS "condition" ` +
`CASE "ix"."indisunique" WHEN 't' THEN 'TRUE' ELSE'FALSE' END AS "is_unique", pg_get_expr("ix"."indpred", "ix"."indrelid") AS "condition", ` +
`"types"."typname" AS "type_name" ` +
`FROM "pg_class" "t" ` +
`INNER JOIN "pg_index" "ix" ON "ix"."indrelid" = "t"."oid" ` +
`INNER JOIN "pg_attribute" "a" ON "a"."attrelid" = "t"."oid" AND "a"."attnum" = ANY ("ix"."indkey") ` +
`INNER JOIN "pg_namespace" "ns" ON "ns"."oid" = "t"."relnamespace" ` +
`INNER JOIN "pg_class" "i" ON "i"."oid" = "ix"."indexrelid" ` +
`INNER JOIN "pg_type" "types" ON "types"."oid" = "a"."atttypid" ` +
`LEFT JOIN "pg_constraint" "cnst" ON "cnst"."conname" = "i"."relname" ` +
`WHERE "t"."relkind" = 'r' AND "cnst"."contype" IS NULL AND (${constraintsCondition})`;
@ -1450,7 +1454,7 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner
columnNames: indices.map(i => i["column_name"]),
isUnique: constraint["is_unique"] === "TRUE",
where: constraint["condition"],
isSpatial: false,
isSpatial: indices.every(i => this.driver.spatialTypes.indexOf(i["type_name"]) >= 0),
isFulltext: false
});
});
@ -1591,7 +1595,7 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner
*/
protected createIndexSql(table: Table, index: TableIndex): string {
const columns = index.columnNames.map(columnName => `"${columnName}"`).join(", ");
return `CREATE ${index.isUnique ? "UNIQUE " : ""}INDEX "${index.name}" ON ${this.escapeTableName(table)}(${columns}) ${index.where ? "WHERE " + index.where : ""}`;
return `CREATE ${index.isUnique ? "UNIQUE " : ""}INDEX "${index.name}" ON ${this.escapeTableName(table)} ${index.isSpatial ? "USING GiST " : ""} (${columns}) ${index.where ? "WHERE " + index.where : ""}`;
}
/**

View File

@ -29,7 +29,7 @@ export interface EntitySchemaIndexOptions {
/**
* The SPATIAL modifier indexes the entire column and does not allow indexed columns to contain NULL values.
* Works only in MySQL.
* Works only in MySQL and PostgreSQL.
*/
spatial?: boolean;

View File

@ -418,6 +418,8 @@ export class InsertQueryBuilder<Entity> extends QueryBuilder<Entity> {
this.expressionMap.nativeParameters[paramName] = value;
if (this.connection.driver instanceof MysqlDriver && this.connection.driver.spatialTypes.indexOf(column.type) !== -1) {
expression += `GeomFromText(${this.connection.driver.createParameter(paramName, parametersCount)})`;
} else if (this.connection.driver instanceof PostgresDriver && this.connection.driver.spatialTypes.indexOf(column.type) !== -1) {
expression += `ST_GeomFromGeoJSON(${this.connection.driver.createParameter(paramName, parametersCount)})::${column.type}`;
} else {
expression += this.connection.driver.createParameter(paramName, parametersCount);
}

View File

@ -1661,6 +1661,10 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
if (this.connection.driver instanceof MysqlDriver)
selectionPath = `AsText(${selectionPath})`;
if (this.connection.driver instanceof PostgresDriver)
// cast to JSON to trigger parsing in the driver
selectionPath = `ST_AsGeoJSON(${selectionPath})::json`;
if (this.connection.driver instanceof SqlServerDriver)
selectionPath = `${selectionPath}.ToString()`;
}

View File

@ -0,0 +1,24 @@
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {Column} from "../../../../../src/decorator/columns/Column";
import {Index} from "../../../../../src/decorator/Index";
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column("geometry", {
nullable: true
})
@Index({
spatial: true
})
geom: object;
@Column("geography", {
nullable: true
})
geog: object;
}

View File

@ -0,0 +1,119 @@
import "reflect-metadata";
import { expect } from "chai";
import { Connection } from "../../../../src/connection/Connection";
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases
} from "../../../utils/test-utils";
import { Post } from "./entity/Post";
describe("spatial-postgres", () => {
let connections: Connection[];
before(async () => {
connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
enabledDrivers: ["postgres"]
});
});
beforeEach(async () => {
try {
await reloadTestingDatabases(connections);
} catch (err) {
console.warn(err.stack);
throw err;
}
});
after(async () => {
try {
await closeTestingConnections(connections);
} catch (err) {
console.warn(err.stack);
throw err;
}
});
it("should create correct schema with Postgres' geometry type", () =>
Promise.all(
connections.map(async connection => {
const queryRunner = connection.createQueryRunner();
const schema = await queryRunner.getTable("post");
await queryRunner.release();
expect(schema).not.to.be.empty;
expect(
schema!.columns.find(
tableColumn =>
tableColumn.name === "geom" && tableColumn.type === "geometry"
)
).to.not.be.empty;
})
));
it("should create correct schema with Postgres' geography type", () =>
Promise.all(
connections.map(async connection => {
const queryRunner = connection.createQueryRunner();
const schema = await queryRunner.getTable("post");
await queryRunner.release();
expect(schema).not.to.be.empty;
expect(
schema!.columns.find(
tableColumn =>
tableColumn.name === "geog" && tableColumn.type === "geography"
)
).to.not.be.empty;
})
));
it("should create correct schema with Postgres' geometry indices", () =>
Promise.all(
connections.map(async connection => {
const queryRunner = connection.createQueryRunner();
const schema = await queryRunner.getTable("post");
await queryRunner.release();
expect(schema).not.to.be.empty;
expect(
schema!.indices.find(
tableIndex =>
tableIndex.isSpatial === true &&
tableIndex.columnNames.length === 1 &&
tableIndex.columnNames[0] === "geom"
)
).to.not.be.empty;
})
));
it("should persist geometry correctly", () =>
Promise.all(
connections.map(async connection => {
const geom = {
type: "Point",
coordinates: [0, 0]
};
const recordRepo = connection.getRepository(Post);
const post = new Post();
post.geom = geom;
const persistedPost = await recordRepo.save(post);
const foundPost = await recordRepo.findOne(persistedPost.id);
expect(foundPost).to.exist;
expect(foundPost!.geom).to.deep.equal(geom);
})
));
it("should persist geography correctly", () =>
Promise.all(
connections.map(async connection => {
const geom = {
type: "Point",
coordinates: [0, 0]
};
const recordRepo = connection.getRepository(Post);
const post = new Post();
post.geog = geom;
const persistedPost = await recordRepo.save(post);
const foundPost = await recordRepo.findOne(persistedPost.id);
expect(foundPost).to.exist;
expect(foundPost!.geog).to.deep.equal(geom);
})
));
});