不要怂,就是干,撸起袖子干!

Commit fe06b45c by Sushant Committed by GitHub

feat(datatypes): array(enum) support for PostgreSQL (#8738)

1 parent f5fcf164
......@@ -36,6 +36,7 @@ before_script:
- "if [ $POSTGRES_VER ]; then sudo mount -t ramfs tmpfs /mnt/sequelize-postgres-ramdisk; fi"
- "if [ $MYSQL_VER ]; then sudo mkdir /mnt/sequelize-mysql-ramdisk; fi"
- "if [ $MYSQL_VER ]; then sudo mount -t ramfs tmpfs /mnt/sequelize-mysql-ramdisk; fi"
# setup docker
- "if [ $POSTGRES_VER ] || [ $MYSQL_VER ]; then docker-compose up -d ${POSTGRES_VER} ${MYSQL_VER}; fi"
- "if [ $MYSQL_VER ]; then docker run --link ${MYSQL_VER}:db -e CHECK_PORT=3306 -e CHECK_HOST=db --net sequelize_default giorgos/takis; fi"
......
......@@ -15,7 +15,7 @@ services:
# PostgreSQL
postgres-95:
image: camptocamp/postgis:9.5
image: sushantdhiman/postgres:9.5
environment:
POSTGRES_USER: sequelize_test
POSTGRES_PASSWORD: sequelize_test
......
......@@ -38,7 +38,7 @@ const Foo = sequelize.define('foo', {
// The unique property is simply a shorthand to create a unique constraint.
someUnique: { type: Sequelize.STRING, unique: true },
// It's exactly the same as creating the index in the model's options.
{ someUnique: { type: Sequelize.STRING } },
{ indexes: [ { unique: true, fields: [ 'someUnique' ] } ] },
......@@ -74,7 +74,7 @@ The comment option can also be used on a table, see [model configuration][0]
## Timestamps
By default, Sequelize will add the attributes `createdAt` and `updatedAt` to your model so you will be able to know when the database entry went into the db and when it was updated last.
By default, Sequelize will add the attributes `createdAt` and `updatedAt` to your model so you will be able to know when the database entry went into the db and when it was updated last.
Note that if you are using Sequelize migrations you will need to add the `createdAt` and `updatedAt` fields to your migration definition:
......@@ -140,6 +140,7 @@ Sequelize.BOOLEAN // TINYINT(1)
Sequelize.ENUM('value 1', 'value 2') // An ENUM with allowed values 'value 1' and 'value 2'
Sequelize.ARRAY(Sequelize.TEXT) // Defines an array. PostgreSQL only.
Sequelize.ARRAY(Sequelize.ENUM) // Defines an array of ENUM. PostgreSQL only.
Sequelize.JSON // JSON column. PostgreSQL, SQLite and MySQL only.
Sequelize.JSONB // JSONB column. PostgreSQL only.
......@@ -198,6 +199,14 @@ sequelize.define('model', {
})
```
### Array(ENUM)
Its only supported with PostgreSQL.
Array(Enum) type require special treatment. Whenever Sequelize will talk to database it has to typecast Array values with ENUM name.
So this enum name must follow this pattern `enum_<table_name>_<col_name>`. If you are using `sync` then correct name will automatically be generated.
### Range types
Since range types have extra information for their bound inclusion/exclusion it's not
......@@ -246,7 +255,7 @@ range.inclusive // [false, true]
Make sure you turn that into a serializable format before serialization since array
extra properties will not be serialized.
#### Special Cases
**Special Cases**
```js
// empty range:
......
......@@ -148,31 +148,18 @@ class ConnectionManager extends AbstractConnectionManager {
}
}
// oids for hstore and geometry are dynamic - so select them at connection time
const supportedVersion = this.sequelize.options.databaseVersion !== 0 && semver.gte(this.sequelize.options.databaseVersion, '8.3.0');
if (dataTypes.HSTORE.types.postgres.oids.length === 0 && supportedVersion) {
query += 'SELECT typname, oid, typarray FROM pg_type WHERE typtype = \'b\' AND typname IN (\'hstore\', \'geometry\', \'geography\')';
if (query) {
return connection.query(query);
}
}).tap(connection => {
if (
dataTypes.GEOGRAPHY.types.postgres.oids.length === 0 &&
dataTypes.GEOMETRY.types.postgres.oids.length === 0 &&
dataTypes.HSTORE.types.postgres.oids.length === 0 &&
dataTypes.ENUM.types.postgres.oids.length === 0
) {
return this._refreshDynamicOIDs(connection);
}
return new Promise((resolve, reject) => connection.query(query, (error, result) => error ? reject(error) : resolve(result))).then(results => {
const result = Array.isArray(results) ? results.pop() : results;
for (const row of result.rows) {
let type;
if (row.typname === 'geometry') {
type = dataTypes.postgres.GEOMETRY;
} else if (row.typname === 'hstore') {
type = dataTypes.postgres.HSTORE;
} else if (row.typname === 'geography') {
type = dataTypes.postgres.GEOGRAPHY;
}
type.types.postgres.oids.push(row.oid);
type.types.postgres.array_oids.push(row.typarray);
this._refreshTypeParser(type);
}
});
});
}
......@@ -186,6 +173,54 @@ class ConnectionManager extends AbstractConnectionManager {
validate(connection) {
return connection._invalid === undefined;
}
_refreshDynamicOIDs(connection) {
const databaseVersion = this.sequelize.options.databaseVersion;
const supportedVersion = '8.3.0';
// Check for supported version
if ( (databaseVersion && semver.gte(databaseVersion, supportedVersion)) === false) {
return Promise.resolve();
}
// Refresh dynamic OIDs for some types
// These include, Geometry / HStore / Enum
return (connection || this.sequelize).query(
"SELECT typname, typtype, oid, typarray FROM pg_type WHERE (typtype = 'b' AND typname IN ('hstore', 'geometry', 'geography')) OR (typtype = 'e')"
).then(results => {
const result = Array.isArray(results) ? results.pop() : results;
// Reset OID mapping for dynamic type
[
dataTypes.postgres.GEOMETRY,
dataTypes.postgres.HSTORE,
dataTypes.postgres.GEOGRAPHY,
dataTypes.postgres.ENUM
].forEach(type => {
type.types.postgres.oids = [];
type.types.postgres.array_oids = [];
});
for (const row of result.rows) {
let type;
if (row.typname === 'geometry') {
type = dataTypes.postgres.GEOMETRY;
} else if (row.typname === 'hstore') {
type = dataTypes.postgres.HSTORE;
} else if (row.typname === 'geography') {
type = dataTypes.postgres.GEOGRAPHY;
} else if (row.typtype === 'e') {
type = dataTypes.postgres.ENUM;
}
type.types.postgres.oids.push(row.oid);
type.types.postgres.array_oids.push(row.typarray);
this._refreshTypeParser(type);
}
});
}
}
_.extend(ConnectionManager.prototype, AbstractConnectionManager.prototype);
......
......@@ -574,12 +574,37 @@ module.exports = BaseTypes => {
}, this).join(',') + ']';
if (this.type) {
str += '::' + this.toSql();
const Utils = require('../../utils');
let castKey = this.toSql();
if (this.type instanceof BaseTypes.ENUM) {
castKey = Utils.addTicks(
Utils.generateEnumName(options.field.Model.getTableName(), options.field.fieldName),
'"'
) + '[]';
}
str += '::' + castKey;
}
return str;
};
function ENUM(options) {
if (!(this instanceof ENUM)) return new ENUM(options);
BaseTypes.ENUM.apply(this, arguments);
}
inherits(ENUM, BaseTypes.ENUM);
ENUM.parse = function(value) {
return value;
};
BaseTypes.ENUM.types.postgres = {
oids: [],
array_oids: []
};
const exports = {
DECIMAL,
BLOB,
......@@ -598,7 +623,8 @@ module.exports = BaseTypes => {
GEOMETRY,
GEOGRAPHY,
HSTORE,
RANGE
RANGE,
ENUM
};
_.forIn(exports, (DataType, key) => {
......
......@@ -496,11 +496,24 @@ const QueryGenerator = {
}
let type;
if (attribute.type instanceof DataTypes.ENUM) {
if (attribute.type.values && !attribute.values) attribute.values = attribute.type.values;
if (
attribute.type instanceof DataTypes.ENUM ||
(attribute.type instanceof DataTypes.ARRAY && attribute.type.type instanceof DataTypes.ENUM)
) {
const enumType = attribute.type.type || attribute.type;
let values = attribute.values;
if (enumType.values && !attribute.values) {
values = enumType.values;
}
if (Array.isArray(values) && values.length > 0) {
type = 'ENUM(' + _.map(values, value => this.escape(value)).join(', ') + ')';
if (attribute.type instanceof DataTypes.ARRAY) {
type += '[]';
}
if (Array.isArray(attribute.values) && attribute.values.length > 0) {
type = 'ENUM(' + _.map(attribute.values, value => this.escape(value)).join(', ') + ')';
} else {
throw new Error("Values for ENUM haven't been defined.");
}
......@@ -736,8 +749,9 @@ const QueryGenerator = {
pgEnumName(tableName, attr, options) {
options = options || {};
const tableDetails = this.extractTableDetails(tableName, options);
let enumName = '"enum_' + tableDetails.tableName + '_' + attr + '"';
let enumName = Utils.addTicks(Utils.generateEnumName(tableDetails.tableName, attr), '"');
// pgListEnums requires the enum name only, without the schema
if (options.schema !== false && tableDetails.schema) {
......@@ -745,7 +759,6 @@ const QueryGenerator = {
}
return enumName;
},
pgListEnums(tableName, attrName, options) {
......
......@@ -177,8 +177,14 @@ class QueryInterface {
const promises = [];
for (i = 0; i < keyLen; i++) {
if (attributes[keys[i]].type instanceof DataTypes.ENUM) {
sql = this.QueryGenerator.pgListEnums(tableName, attributes[keys[i]].field || keys[i], options);
const attribute = attributes[keys[i]];
const type = attribute.type;
if (
type instanceof DataTypes.ENUM ||
(type instanceof DataTypes.ARRAY && type.type instanceof DataTypes.ENUM) //ARRAY sub type is ENUM
) {
sql = this.QueryGenerator.pgListEnums(tableName, attribute.field || keys[i], options);
promises.push(this.sequelize.query(
sql,
_.assign({}, options, { plain: true, raw: true, type: QueryTypes.SELECT })
......@@ -191,17 +197,24 @@ class QueryInterface {
let enumIdx = 0;
for (i = 0; i < keyLen; i++) {
if (attributes[keys[i]].type instanceof DataTypes.ENUM) {
const attribute = attributes[keys[i]];
const type = attribute.type;
const enumType = type.type || type;
if (
type instanceof DataTypes.ENUM ||
(type instanceof DataTypes.ARRAY && enumType instanceof DataTypes.ENUM) //ARRAY sub type is ENUM
) {
// If the enum type doesn't exist then create it
if (!results[enumIdx]) {
sql = this.QueryGenerator.pgEnum(tableName, attributes[keys[i]].field || keys[i], attributes[keys[i]], options);
sql = this.QueryGenerator.pgEnum(tableName, attribute.field || keys[i], enumType, options);
promises.push(this.sequelize.query(
sql,
_.assign({}, options, { raw: true })
));
} else if (!!results[enumIdx] && !!model) {
const enumVals = this.QueryGenerator.fromArray(results[enumIdx].enum_value);
const vals = model.rawAttributes[keys[i]].values;
const vals = enumType.values;
vals.forEach((value, idx) => {
// reset out after/before options since it's for every enum value
......@@ -217,7 +230,7 @@ class QueryInterface {
valueOptions.after = vals[idx - 1];
}
valueOptions.supportsSearchPath = false;
promises.push(this.sequelize.query(this.QueryGenerator.pgEnumAdd(tableName, keys[i], value, valueOptions), valueOptions));
promises.push(this.sequelize.query(this.QueryGenerator.pgEnumAdd(tableName, attribute.field || keys[i], value, valueOptions), valueOptions));
}
});
enumIdx++;
......@@ -238,9 +251,19 @@ class QueryInterface {
});
sql = this.QueryGenerator.createTableQuery(tableName, attributes, options);
return Promise.all(promises).then(() => {
return this.sequelize.query(sql, options);
});
return Promise.all(promises)
.tap(() => {
// If ENUM processed, then refresh OIDs
if (promises.length) {
return this.sequelize.dialect.connectionManager._refreshDynamicOIDs()
.then(() => {
return this.sequelize.refreshTypes(DataTypes.postgres);
});
}
})
.then(() => {
return this.sequelize.query(sql, options);
});
});
} else {
if (!tableName.schema &&
......
......@@ -612,3 +612,17 @@ function isWhereEmpty(obj) {
return _.isEmpty(obj) && getOperators(obj).length === 0;
}
exports.isWhereEmpty = isWhereEmpty;
/**
* Returns ENUM name by joining table and column name
*
* @param {String} tableName
* @param {String} columnName
* @return {String}
* @private
*/
function generateEnumName(tableName, columnName) {
return 'enum_' + tableName + '_' + columnName;
}
exports.generateEnumName = generateEnumName;
......@@ -99,14 +99,15 @@
],
"main": "index",
"options": {
"env_cmd": "./test/config/.docker.env",
"mocha": "--globals setImmediate,clearImmediate --ui tdd --exit --check-leaks --colors -t 30000 --reporter spec"
},
"scripts": {
"lint": "eslint lib test --quiet",
"test": "npm run teaser && npm run test-unit && npm run test-integration",
"test-docker": "npm run test-docker-unit && npm run test-docker-integration",
"test-docker-unit": "env-cmd ./test/config/.docker.env npm run test-unit",
"test-docker-integration": "env-cmd ./test/config/.docker.env npm run test-integration",
"test-docker-unit": "npm run test-unit",
"test-docker-integration": "env-cmd $npm_package_options_env_cmd npm run test-integration",
"docs": "esdoc && cp docs/ROUTER esdoc/ROUTER",
"teaser": "node -e \"console.log('#'.repeat(process.env.DIALECT.length + 22) + '\\n# Running tests for ' + process.env.DIALECT + ' #\\n' + '#'.repeat(process.env.DIALECT.length + 22))\"",
"test-unit": "mocha $npm_package_options_mocha \"test/unit/**/*.js\"",
......@@ -132,10 +133,10 @@
"test-mssql": "cross-env DIALECT=mssql npm test",
"test-all": "npm run test-mysql && npm run test-sqlite && npm run test-postgres && npm run test-postgres-native && npm run test-mssql",
"cover": "rimraf coverage && npm run teaser && npm run cover-integration && npm run cover-unit && npm run merge-coverage",
"cover-integration": "cross-env COVERAGE=true node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -t 60000 --exit --ui tdd \"test/integration/**/*.test.js\" && node -e \"require('fs').renameSync('coverage/lcov.info', 'coverage/integration.info')\"",
"cover-unit": "cross-env COVERAGE=true node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -t 30000 --exit --ui tdd \"test/unit/**/*.test.js\" && node -e \"require('fs').renameSync('coverage/lcov.info', 'coverage/unit.info')\"",
"cover-integration": "cross-env COVERAGE=true ./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -t 30000 --exit --ui tdd \"test/integration/**/*.test.js\" && node -e \"require('fs').renameSync('coverage/lcov.info', 'coverage/integration.info')\"",
"cover-unit": "cross-env COVERAGE=true ./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -t 30000 --exit --ui tdd \"test/unit/**/*.test.js\" && node -e \"require('fs').renameSync('coverage/lcov.info', 'coverage/unit.info')\"",
"merge-coverage": "lcov-result-merger \"coverage/*.info\" \"coverage/lcov.info\"",
"sscce": "env-cmd ./test/config/.docker.env node sscce.js",
"sscce": "env-cmd $npm_package_options_env_cmd node sscce.js",
"sscce-mysql": "cross-env DIALECT=mysql npm run sscce",
"sscce-postgres": "cross-env DIALECT=postgres npm run sscce",
"sscce-sqlite": "cross-env DIALECT=sqlite npm run sscce",
......
......@@ -266,8 +266,11 @@ describe(Support.getTestDialectTeaser('DataTypes'), () => {
it('calls parse and stringify for ENUM', () => {
const Type = new Sequelize.ENUM('hat', 'cat');
// No dialects actually allow us to identify that we get an enum back..
testFailure(Type);
if (['postgres'].indexOf(dialect) !== -1) {
return testSuccess(Type, 'hat');
} else {
testFailure(Type);
}
});
if (current.dialect.supports.GEOMETRY) {
......
......@@ -315,6 +315,38 @@ if (dialect.match(/^postgres/)) {
});
});
it('should be able to create/drop multiple enums multiple times with field name (#7812)', function() {
const DummyModel = this.sequelize.define('Dummy-pg', {
username: DataTypes.STRING,
theEnumOne: {
field: 'oh_my_this_enum_one',
type: DataTypes.ENUM,
values: [
'one',
'two',
'three'
]
},
theEnumTwo: {
field: 'oh_my_this_enum_two',
type: DataTypes.ENUM,
values: [
'four',
'five',
'six'
]
}
});
return DummyModel.sync({ force: true }).then(() => {
// now sync one more time:
return DummyModel.sync({ force: true }).then(() => {
// sync without dropping
return DummyModel.sync();
});
});
});
it('should be able to add values to enum types', function() {
let User = this.sequelize.define('UserEnums', {
mood: DataTypes.ENUM('happy', 'sad', 'meh')
......@@ -333,6 +365,161 @@ if (dialect.match(/^postgres/)) {
expect(enums[0].enum_value).to.equal('{neutral,happy,sad,ecstatic,meh,joyful}');
});
});
describe('ARRAY(ENUM)', () => {
it('should be able to ignore enum types that already exist', function() {
const User = this.sequelize.define('UserEnums', {
permissions: DataTypes.ARRAY(DataTypes.ENUM([
'access',
'write',
'check',
'delete'
]))
});
return User.sync({ force: true }).then(() => User.sync());
});
it('should be able to create/drop enums multiple times', function() {
const User = this.sequelize.define('UserEnums', {
permissions: DataTypes.ARRAY(DataTypes.ENUM([
'access',
'write',
'check',
'delete'
]))
});
return User.sync({ force: true }).then(() => User.sync({ force: true }));
});
it('should be able to add values to enum types', function() {
let User = this.sequelize.define('UserEnums', {
permissions: DataTypes.ARRAY(DataTypes.ENUM([
'access',
'write',
'check',
'delete'
]))
});
return User.sync({ force: true }).then(() => {
User = this.sequelize.define('UserEnums', {
permissions: DataTypes.ARRAY(
DataTypes.ENUM('view', 'access', 'edit', 'write', 'check', 'delete')
)
});
return User.sync();
}).then(() => {
return this.sequelize.getQueryInterface().pgListEnums(User.getTableName());
}).then(enums => {
expect(enums).to.have.length(1);
expect(enums[0].enum_value).to.equal('{view,access,edit,write,check,delete}');
});
});
it('should be able to insert new record', function() {
const User = this.sequelize.define('UserEnums', {
name: DataTypes.STRING,
type: DataTypes.ENUM('A', 'B', 'C'),
owners: DataTypes.ARRAY(DataTypes.STRING),
permissions: DataTypes.ARRAY(DataTypes.ENUM([
'access',
'write',
'check',
'delete'
]))
});
return User.sync({ force: true })
.then(() => {
return User.create({
name: 'file.exe',
type: 'C',
owners: ['userA', 'userB'],
permissions: ['access', 'write']
});
})
.then(user => {
expect(user.name).to.equal('file.exe');
expect(user.type).to.equal('C');
expect(user.owners).to.deep.equal(['userA', 'userB']);
expect(user.permissions).to.deep.equal(['access', 'write']);
});
});
it('should fail when trying to insert foreign element on ARRAY(ENUM)', function() {
const User = this.sequelize.define('UserEnums', {
name: DataTypes.STRING,
type: DataTypes.ENUM('A', 'B', 'C'),
owners: DataTypes.ARRAY(DataTypes.STRING),
permissions: DataTypes.ARRAY(DataTypes.ENUM([
'access',
'write',
'check',
'delete'
]))
});
return expect(User.sync({ force: true }).then(() => {
return User.create({
name: 'file.exe',
type: 'C',
owners: ['userA', 'userB'],
permissions: ['cosmic_ray_disk_access']
});
})).to.be.rejectedWith(/invalid input value for enum "enum_UserEnums_permissions": "cosmic_ray_disk_access"/);
});
it('should be able to find records', function() {
const User = this.sequelize.define('UserEnums', {
name: DataTypes.STRING,
type: DataTypes.ENUM('A', 'B', 'C'),
permissions: DataTypes.ARRAY(DataTypes.ENUM([
'access',
'write',
'check',
'delete'
]))
});
return User.sync({ force: true })
.then(() => {
return User.bulkCreate([{
name: 'file1.exe',
type: 'C',
permissions: ['access', 'write']
}, {
name: 'file2.exe',
type: 'A',
permissions: ['access', 'check']
}, {
name: 'file3.exe',
type: 'B',
permissions: ['access', 'write', 'delete']
}]);
})
.then(() => {
return User.findAll({
where: {
type: {
$in: ['A', 'C']
},
permissions: {
$contains: ['write']
}
}
});
})
.then(users => {
expect(users.length).to.equal(1);
expect(users[0].name).to.equal('file1.exe');
expect(users[0].type).to.equal('C');
expect(users[0].permissions).to.deep.equal(['access', 'write']);
});
});
});
});
describe('integers', () => {
......
'use strict';
const Support = require('../support'),
dialect = Support.getTestDialect();
before(() => {
if (dialect !== 'postgres' && dialect !== 'postgres-native') {
return;
}
return Support.sequelize.Promise.all([
Support.sequelize.query('CREATE EXTENSION IF NOT EXISTS hstore', {raw: true}),
Support.sequelize.query('CREATE EXTENSION IF NOT EXISTS btree_gist', {raw: true})
]);
});
const Support = require('../support');
beforeEach(function() {
this.sequelize.test.trackRunningQueries();
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!