diff --git a/docs/entities.md b/docs/entities.md index a5893b54ac..9b562454ce 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -311,7 +311,7 @@ or `date`, `time`, `time without time zone`, `time with time zone`, `interval`, `bool`, `boolean`, `enum`, `point`, `line`, `lseg`, `box`, `path`, `polygon`, `circle`, `cidr`, `inet`, `macaddr`, `tsvector`, `tsquery`, `uuid`, `xml`, `json`, `jsonb`, `int4range`, `int8range`, `numrange`, -`tsrange`, `tstzrange`, `daterange`, `geometry`, `geography`, `cube` +`tsrange`, `tstzrange`, `daterange`, `geometry`, `geography`, `cube`, `ltree` ### Column types for `cockroachdb` diff --git a/src/driver/postgres/PostgresDriver.ts b/src/driver/postgres/PostgresDriver.ts index 5aa25b585d..e9eae242c6 100644 --- a/src/driver/postgres/PostgresDriver.ts +++ b/src/driver/postgres/PostgresDriver.ts @@ -151,7 +151,8 @@ export class PostgresDriver implements Driver { "daterange", "geometry", "geography", - "cube" + "cube", + "ltree" ]; /** @@ -328,6 +329,7 @@ export class PostgresDriver implements Driver { hasHstoreColumns, hasCubeColumns, hasGeometryColumns, + hasLtreeColumns, hasExclusionConstraints, } = extensionsMetadata; @@ -361,6 +363,12 @@ export class PostgresDriver implements Driver { } catch (_) { logger.log("warn", "At least one of the entities has a cube column, but the 'cube' extension cannot be installed automatically. Please install it manually using superuser rights"); } + if (hasLtreeColumns) + try { + await this.executeQuery(connection, `CREATE EXTENSION IF NOT EXISTS "ltree"`); + } catch (_) { + logger.log("warn", "At least one of the entities has a cube column, but the 'ltree' extension cannot be installed automatically. Please install it manually using superuser rights"); + } if (hasExclusionConstraints) try { // The btree_gist extension provides operator support in PostgreSQL exclusion constraints @@ -386,6 +394,9 @@ export class PostgresDriver implements Driver { const hasGeometryColumns = this.connection.entityMetadatas.some(metadata => { return metadata.columns.filter(column => this.spatialTypes.indexOf(column.type) >= 0).length > 0; }); + const hasLtreeColumns = this.connection.entityMetadatas.some(metadata => { + return metadata.columns.filter(column => column.type === 'ltree').length > 0; + }); const hasExclusionConstraints = this.connection.entityMetadatas.some(metadata => { return metadata.exclusions.length > 0; }); @@ -396,8 +407,9 @@ export class PostgresDriver implements Driver { hasHstoreColumns, hasCubeColumns, hasGeometryColumns, + hasLtreeColumns, hasExclusionConstraints, - hasExtensions: hasUuidColumns || hasCitextColumns || hasHstoreColumns || hasGeometryColumns || hasCubeColumns || hasExclusionConstraints, + hasExtensions: hasUuidColumns || hasCitextColumns || hasHstoreColumns || hasGeometryColumns || hasCubeColumns || hasLtreeColumns || hasExclusionConstraints, }; } @@ -487,6 +499,8 @@ export class PostgresDriver implements Driver { } return `(${value.join(",")})`; + } else if (columnMetadata.type === "ltree") { + return value.split(".").filter(Boolean).join('.').replace(/[\s]+/g, "_"); } else if ( ( columnMetadata.type === "enum" diff --git a/src/driver/types/ColumnTypes.ts b/src/driver/types/ColumnTypes.ts index 3569bedda3..44c3ab2b85 100644 --- a/src/driver/types/ColumnTypes.ts +++ b/src/driver/types/ColumnTypes.ts @@ -193,7 +193,8 @@ export type SimpleColumnType = |"uniqueidentifier" // mssql |"rowversion" // mssql |"array" // cockroachdb, sap - |"cube"; // postgres + |"cube" // postgres + |"ltree"; // postgres /** * Any column type column can be. diff --git a/test/functional/ltree/postgres/entity/Post.ts b/test/functional/ltree/postgres/entity/Post.ts new file mode 100644 index 0000000000..8abbf39803 --- /dev/null +++ b/test/functional/ltree/postgres/entity/Post.ts @@ -0,0 +1,14 @@ +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {Column} from "../../../../../src/decorator/columns/Column"; + +@Entity() +export class Post { + @PrimaryGeneratedColumn() + id: number; + + @Column("ltree", { + nullable: false + }) + path: string; +} diff --git a/test/functional/ltree/postgres/ltree-postgres.ts b/test/functional/ltree/postgres/ltree-postgres.ts new file mode 100644 index 0000000000..7e19718d84 --- /dev/null +++ b/test/functional/ltree/postgres/ltree-postgres.ts @@ -0,0 +1,136 @@ +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("ltree-postgres", () => { + let connections: Connection[]; + before(async () => { + connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + enabledDrivers: ["postgres"] + }); + }); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should create correct schema with Postgres' ltree 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.undefined; + const ltreeColumn = schema!.columns.find( + tableColumn => + tableColumn.name === "path" && + tableColumn.type === "ltree" && + !tableColumn.isArray + ); + expect(ltreeColumn).to.not.be.undefined; + }) + )); + + it("should persist ltree correctly", () => + Promise.all( + connections.map(async connection => { + const path = 'News.Featured.Opinion'; + const postRepo = connection.getRepository(Post); + const post = new Post(); + post.path = path; + const persistedPost = await postRepo.save(post); + const foundPost = await postRepo.findOne(persistedPost.id); + expect(foundPost).to.exist; + expect(foundPost!.path).to.deep.equal(path); + }) + )); + + it("should update ltree correctly", () => + Promise.all( + connections.map(async connection => { + const path = 'News.Featured.Opinion'; + const path2 = 'News.Featured.Gossip'; + const postRepo = connection.getRepository(Post); + const post = new Post(); + post.path = path; + const persistedPost = await postRepo.save(post); + + await postRepo.update( + { id: persistedPost.id }, + { path: path2 } + ); + + const foundPost = await postRepo.findOne(persistedPost.id); + expect(foundPost).to.exist; + expect(foundPost!.path).to.deep.equal(path2); + }) + )); + + it("should re-save ltree correctly", () => + Promise.all( + connections.map(async connection => { + const path = 'News.Featured.Opinion'; + const path2 = 'News.Featured.Gossip'; + const postRepo = connection.getRepository(Post); + const post = new Post(); + post.path = path; + const persistedPost = await postRepo.save(post); + + persistedPost.path = path2; + await postRepo.save(persistedPost); + + const foundPost = await postRepo.findOne(persistedPost.id); + expect(foundPost).to.exist; + expect(foundPost!.path).to.deep.equal(path2); + }) + )); + + it("should persist ltree correctly with trailing '.'", () => + Promise.all( + connections.map(async connection => { + const path = 'News.Featured.Opinion.'; + const postRepo = connection.getRepository(Post); + const post = new Post(); + post.path = path; + const persistedPost = await postRepo.save(post); + const foundPost = await postRepo.findOne(persistedPost.id); + expect(foundPost).to.exist; + expect(foundPost!.path).to.deep.equal('News.Featured.Opinion'); + }) + )); + + it("should persist ltree correctly when containing spaces", () => + Promise.all( + connections.map(async connection => { + const path = 'News.Featured Story.Opinion'; + const postRepo = connection.getRepository(Post); + const post = new Post(); + post.path = path; + const persistedPost = await postRepo.save(post); + const foundPost = await postRepo.findOne(persistedPost.id); + expect(foundPost).to.exist; + expect(foundPost!.path).to.deep.equal('News.Featured_Story.Opinion'); + }) + )); + + it("should be able to query ltree correctly", () => + Promise.all( + connections.map(async connection => { + const path = 'News.Featured.Opinion'; + const postRepo = connection.getRepository(Post); + const post = new Post(); + post.path = path; + await postRepo.save(post); + const foundPost = await postRepo.createQueryBuilder().where(`path ~ 'news@.*'`).getOne() + expect(foundPost).to.exist; + expect(foundPost!.path).to.deep.equal(path); + }) + )); +}); + +