Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spatial support for PostgreSQL using PostGIS #2423

Merged
merged 18 commits into from Jul 3, 2018
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Expand Up @@ -39,7 +39,7 @@ services:

# postgres
postgres:
image: "postgres:9.6.1"
image: "mdillon/postgis:9.6"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess somebody else can easily create a PR for the feature with specific extension and put another docker image with that extension, but without gis let's say. So better solution would be to have a base postgres image with all modifications applied

Copy link
Contributor Author

@mojodna mojodna Jul 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. mdillon/postgis is just postgres + PostGIS (pretty sure it doesn't include any other additional extensions). Is this worth punting on until someone else needs to add another extension? (We can add a note/apology here ;-)

Copy link
Member

@pleerock pleerock Jul 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha yeah... I hope it won't be me 😆

container_name: "typeorm-postgres"
ports:
- "5432:5432"
Expand Down
9 changes: 8 additions & 1 deletion src/decorator/columns/Column.ts
@@ -1,10 +1,11 @@
import {ColumnOptions, getMetadataArgsStorage} from "../../";
import {
ColumnType, SimpleColumnType, WithLengthColumnType,
ColumnType, SimpleColumnType, SpatialColumnType, WithLengthColumnType,
WithPrecisionColumnType, WithWidthColumnType
} from "../../driver/types/ColumnTypes";
import {ColumnMetadataArgs} from "../../metadata-args/ColumnMetadataArgs";
import {ColumnCommonOptions} from "../options/ColumnCommonOptions";
import {SpatialColumnOptions} from "../options/SpatialColumnOptions";
import {ColumnWithLengthOptions} from "../options/ColumnWithLengthOptions";
import {ColumnNumericOptions} from "../options/ColumnNumericOptions";
import {ColumnEnumOptions} from "../options/ColumnEnumOptions";
Expand Down Expand Up @@ -32,6 +33,12 @@ export function Column(options: ColumnOptions): Function;
*/
export function Column(type: SimpleColumnType, options?: ColumnCommonOptions): Function;

/**
* Column decorator is used to mark a specific class property as a table column.
* Only properties decorated with this decorator will be persisted to the database when entity be saved.
*/
export function Column(type: SpatialColumnType, options?: ColumnCommonOptions & SpatialColumnOptions): Function;

/**
* Column decorator is used to mark a specific class property as a table column.
* Only properties decorated with this decorator will be persisted to the database when entity be saved.
Expand Down
11 changes: 10 additions & 1 deletion src/decorator/options/ColumnOptions.ts
Expand Up @@ -139,5 +139,14 @@ export interface ColumnOptions {
* this column when reading or writing to the database.
*/
transformer?: ValueTransformer;


/**
* Spatial Feature Type (Geometry, Point, Polygon, etc.)
*/
spatialFeatureType?: string;

/**
* SRID (Spatial Reference ID (EPSG code))
*/
srid?: number;
}
2 changes: 1 addition & 1 deletion src/decorator/options/IndexOptions.ts
Expand Up @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions src/decorator/options/SpatialColumnOptions.ts
@@ -0,0 +1,18 @@
/**
* Options for spatial columns.
*/
export interface SpatialColumnOptions {

/**
* Column type's feature type.
* Geometry, Point, Polygon, etc.
*/
spatialFeatureType?: string;

/**
* Column type's SRID.
* Spatial Reference ID or EPSG code.
*/
srid?: number;

}
34 changes: 29 additions & 5 deletions src/driver/postgres/PostgresDriver.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand All @@ -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();
});
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previous one was better =)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waits for Array.includes in TS...

Local style should be:

} else if (columnMetadata.type === "json" ||
           columnMetadata.type === "jsonb" ||
           this.spatialTypes.indexOf(columnMetadata.type) >= 0) {

Copy link
Member

@pleerock pleerock Jul 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, thats fine as well, you can keep it. I simply don't like concat, I would either go with "local style" you wrote or ["json", "jsob", ... this.spatialTypes].indexOf() which looks much better than concat

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right, I always forget about spreads in this context.

return JSON.stringify(value);

} else if (columnMetadata.type === "hstore") {
Expand Down Expand Up @@ -622,6 +636,14 @@ export class PostgresDriver implements Driver {

} else if (column.type === "timestamp with time zone") {
type = "TIMESTAMP" + (column.precision !== null && column.precision !== undefined ? "(" + column.precision + ")" : "") + " WITH TIME ZONE";
} else if (this.spatialTypes.indexOf(column.type as ColumnType) >= 0) {
if (column.spatialFeatureType != null && column.srid != null) {
type = `${column.type}(${column.spatialFeatureType},${column.srid})`;
} else if (column.spatialFeatureType != null) {
type = `${column.type}(${column.spatialFeatureType})`;
} else {
type = column.type;
}
}

if (column.isArray)
Expand Down Expand Up @@ -699,7 +721,9 @@ export class PostgresDriver implements Driver {
|| tableColumn.isNullable !== columnMetadata.isNullable
|| tableColumn.isUnique !== this.normalizeIsUnique(columnMetadata)
|| (tableColumn.enum && columnMetadata.enum && !OrmUtils.isArraysEqual(tableColumn.enum, columnMetadata.enum))
|| tableColumn.isGenerated !== columnMetadata.isGenerated;
|| tableColumn.isGenerated !== columnMetadata.isGenerated
|| (tableColumn.spatialFeatureType || "").toLowerCase() !== (columnMetadata.spatialFeatureType || "").toLowerCase()
|| tableColumn.srid !== columnMetadata.srid;
});
}

Expand Down
52 changes: 48 additions & 4 deletions src/driver/postgres/PostgresQueryRunner.ts
Expand Up @@ -812,6 +812,12 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner
downQueries.push(`ALTER TABLE ${this.escapeTableName(table)} ALTER COLUMN "${newColumn.name}" SET DEFAULT ${oldColumn.default}`);
}
}

if ((newColumn.spatialFeatureType || "").toLowerCase() !== (oldColumn.spatialFeatureType || "").toLowerCase() || newColumn.srid !== oldColumn.srid) {
upQueries.push(`ALTER TABLE ${this.escapeTableName(table)} ALTER COLUMN "${newColumn.name}" TYPE ${this.driver.createFullType(newColumn)}`);
downQueries.push(`ALTER TABLE ${this.escapeTableName(table)} ALTER COLUMN "${newColumn.name}" TYPE ${this.driver.createFullType(oldColumn)}`);
}

}

await this.executeQueries(upQueries, downQueries);
Expand Down Expand Up @@ -1182,7 +1188,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);
Expand Down Expand Up @@ -1242,12 +1250,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})`;

Expand Down Expand Up @@ -1348,6 +1358,40 @@ export class PostgresQueryRunner extends BaseQueryRunner implements QueryRunner
tableColumn.enum = results.map(result => result["value"]);
}

if (tableColumn.type === "geometry") {
const geometryColumnSql = `SELECT * FROM (
SELECT
f_table_schema table_schema,
f_table_name table_name,
f_geometry_column column_name,
srid,
type
FROM geometry_columns
) AS _
WHERE ${tablesCondition} AND column_name = '${tableColumn.name}'`;

const results: ObjectLiteral[] = await this.query(geometryColumnSql);
tableColumn.spatialFeatureType = results[0].type;
tableColumn.srid = results[0].srid;
}

if (tableColumn.type === "geography") {
const geographyColumnSql = `SELECT * FROM (
SELECT
f_table_schema table_schema,
f_table_name table_name,
f_geography_column column_name,
srid,
type
FROM geography_columns
) AS _
WHERE ${tablesCondition} AND column_name = '${tableColumn.name}'`;

const results: ObjectLiteral[] = await this.query(geographyColumnSql);
tableColumn.spatialFeatureType = results[0].type;
tableColumn.srid = results[0].srid;
}

// check only columns that have length property
if (this.driver.withLengthColumnTypes.indexOf(tableColumn.type as ColumnType) !== -1 && dbColumn["character_maximum_length"]) {
const length = dbColumn["character_maximum_length"].toString();
Expand Down Expand Up @@ -1450,7 +1494,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
});
});
Expand Down Expand Up @@ -1591,7 +1635,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 : ""}`;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/driver/types/ColumnTypes.ts
Expand Up @@ -16,6 +16,12 @@ export type PrimaryGeneratedColumnType = "int" // mysql, mssql, oracle, sqlite
|"numeric" // postgres, mssql, sqlite
|"number"; // oracle

/**
* Column types where spatial properties are used.
*/
export type SpatialColumnType = "geometry" // postgres
|"geography"; // postgres

/**
* Column types where precision and scale properties are used.
*/
Expand Down Expand Up @@ -171,6 +177,7 @@ export type SimpleColumnType =
export type ColumnType = WithPrecisionColumnType
|WithLengthColumnType
|WithWidthColumnType
|SpatialColumnType
|SimpleColumnType
|BooleanConstructor
|DateConstructor
Expand Down
2 changes: 1 addition & 1 deletion src/entity-schema/EntitySchemaIndexOptions.ts
Expand Up @@ -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;

Expand Down
14 changes: 14 additions & 0 deletions src/metadata/ColumnMetadata.ts
Expand Up @@ -278,6 +278,16 @@ export class ColumnMetadata {
*/
isMaterializedPath: boolean = false;

/**
* Spatial Feature Type (Geometry, Point, Polygon, etc.)
*/
spatialFeatureType?: string;

/**
* SRID (Spatial Reference ID (EPSG code))
*/
srid?: number;

// ---------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------
Expand Down Expand Up @@ -366,6 +376,10 @@ export class ColumnMetadata {
}
if (options.args.options.transformer)
this.transformer = options.args.options.transformer;
if (options.args.options.spatialFeatureType)
this.spatialFeatureType = options.args.options.spatialFeatureType;
if (options.args.options.srid)
this.srid = options.args.options.srid;
if (this.isTreeLevel)
this.type = options.connection.driver.mappedDataTypes.treeLevel;
if (this.isCreateDate) {
Expand Down
6 changes: 6 additions & 0 deletions src/query-builder/InsertQueryBuilder.ts
Expand Up @@ -418,6 +418,12 @@ 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) {
if (column.srid != null) {
expression += `ST_SetSRID(ST_GeomFromGeoJSON(${this.connection.driver.createParameter(paramName, parametersCount)}), ${column.srid})::${column.type}`;
} else {
expression += `ST_GeomFromGeoJSON(${this.connection.driver.createParameter(paramName, parametersCount)})::${column.type}`;
}
} else {
expression += this.connection.driver.createParameter(paramName, parametersCount);
}
Expand Down
4 changes: 1 addition & 3 deletions src/query-builder/QueryBuilder.ts
Expand Up @@ -319,9 +319,7 @@ export abstract class QueryBuilder<Entity> {
if (this.expressionMap.parentQueryBuilder)
this.expressionMap.parentQueryBuilder.setParameters(parameters);

Object.keys(parameters).forEach(key => {
this.expressionMap.parameters[key] = parameters[key];
});
Object.keys(parameters).forEach(key => this.setParameter(key, parameters[key]));
return this;
}

Expand Down
4 changes: 4 additions & 0 deletions src/query-builder/SelectQueryBuilder.ts
Expand Up @@ -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()`;
}
Expand Down
14 changes: 13 additions & 1 deletion src/query-builder/UpdateQueryBuilder.ts
Expand Up @@ -393,7 +393,19 @@ export class UpdateQueryBuilder<Entity> extends QueryBuilder<Entity> implements
this.expressionMap.nativeParameters[paramName] = value;
}

updateColumnAndValues.push(this.escape(column.databaseName) + " = " + this.connection.driver.createParameter(paramName, parametersCount));
let expression = null;
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) {
if (column.srid != null) {
expression = `ST_SetSRID(ST_GeomFromGeoJSON(${this.connection.driver.createParameter(paramName, parametersCount)}), ${column.srid})::${column.type}`;
} else {
expression = `ST_GeomFromGeoJSON(${this.connection.driver.createParameter(paramName, parametersCount)})::${column.type}`;
}
} else {
expression = this.connection.driver.createParameter(paramName, parametersCount);
}
updateColumnAndValues.push(this.escape(column.databaseName) + " = " + expression);
parametersCount++;
}
});
Expand Down
12 changes: 11 additions & 1 deletion src/schema-builder/options/TableColumnOptions.ts
Expand Up @@ -122,4 +122,14 @@ export interface TableColumnOptions {
* Generated column type. Supports only in MySQL.
*/
generatedType?: "VIRTUAL"|"STORED";
}

/**
* Spatial Feature Type (Geometry, Point, Polygon, etc.)
*/
spatialFeatureType?: string;

/**
* SRID (Spatial Reference ID (EPSG code))
*/
srid?: number;
}