mirror of
https://github.com/typeorm/typeorm.git
synced 2025-12-08 21:26:23 +00:00
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:
parent
51b2a63d91
commit
1e20c6b04a
@ -39,7 +39,7 @@ services:
|
||||
|
||||
# postgres
|
||||
postgres:
|
||||
image: "postgres:9.6.1"
|
||||
image: "mdillon/postgis:9.6"
|
||||
container_name: "typeorm-postgres"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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 : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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()`;
|
||||
}
|
||||
|
||||
24
test/functional/spatial/postgres/entity/Post.ts
Normal file
24
test/functional/spatial/postgres/entity/Post.ts
Normal 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;
|
||||
}
|
||||
119
test/functional/spatial/postgres/spatial-postgres.ts
Normal file
119
test/functional/spatial/postgres/spatial-postgres.ts
Normal 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);
|
||||
})
|
||||
));
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user