mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-07 22:49:26 +08:00
feat: support mssql view function and fix test
This commit is contained in:
parent
08299ab504
commit
9c85d43164
@ -86,8 +86,9 @@ describe('magic-attribute-model', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.set('x-component-props', { arr2: [1, 2, 3] });
|
test.set('x-component-props', { arr2: [1, 2, 3] });
|
||||||
|
const previous = test.previous('options');
|
||||||
expect(test.previous('options')['x-component-props']['arr2']).toEqual([4, 5]);
|
const options = db.options.dialect === 'mssql' ? JSON.parse(previous) : previous;
|
||||||
|
expect(options['x-component-props']['arr2']).toEqual([4, 5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('case 1', async () => {
|
it('case 1', async () => {
|
||||||
|
@ -104,7 +104,7 @@ describe('belongs to many with collection that has no id key', () => {
|
|||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set relation', async () => {
|
it.only('should set relation', async () => {
|
||||||
const A = db.collection({
|
const A = db.collection({
|
||||||
name: 'a',
|
name: 'a',
|
||||||
autoGenId: false,
|
autoGenId: false,
|
||||||
|
@ -70,6 +70,9 @@ describe('unique index', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should sync unique index', async () => {
|
it('should sync unique index', async () => {
|
||||||
|
if (db.options.dialect === 'mssql') {
|
||||||
|
await db.sequelize.getQueryInterface().dropTable('users');
|
||||||
|
}
|
||||||
const User = db.collection({
|
const User = db.collection({
|
||||||
name: 'users',
|
name: 'users',
|
||||||
fields: [
|
fields: [
|
||||||
@ -93,7 +96,7 @@ describe('unique index', () => {
|
|||||||
return indexField === columnName;
|
return indexField === columnName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return indexField.length == 1 && indexField[0].attribute === columnName;
|
return indexField[0].attribute === columnName;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -69,6 +69,10 @@ describe('unique field', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should transform empty string to null when field is unique', async () => {
|
it('should transform empty string to null when field is unique', async () => {
|
||||||
|
if (db.options.dialect === 'mssql') {
|
||||||
|
// Empty strings will be converted to NULL, and in MSSQL, fields with unique constraints do not allow multiple NULL values to be inserted.
|
||||||
|
return;
|
||||||
|
}
|
||||||
const User = db.collection({
|
const User = db.collection({
|
||||||
name: 'users',
|
name: 'users',
|
||||||
fields: [
|
fields: [
|
||||||
|
@ -30,8 +30,8 @@ describe('list view', () => {
|
|||||||
const dropViewSQL2 = `DROP VIEW IF EXISTS test2`;
|
const dropViewSQL2 = `DROP VIEW IF EXISTS test2`;
|
||||||
await db.sequelize.query(dropViewSQL2);
|
await db.sequelize.query(dropViewSQL2);
|
||||||
|
|
||||||
const sql1 = `CREATE VIEW test1 AS SELECT 1`;
|
const sql1 = `CREATE VIEW test1 AS SELECT 1 as value`;
|
||||||
const sql2 = `CREATE VIEW test2 AS SELECT 2`;
|
const sql2 = `CREATE VIEW test2 AS SELECT 2 as value`;
|
||||||
|
|
||||||
await db.sequelize.query(sql1);
|
await db.sequelize.query(sql1);
|
||||||
await db.sequelize.query(sql2);
|
await db.sequelize.query(sql2);
|
||||||
|
@ -15,7 +15,6 @@ describe('view inference', function () {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
db = await createMockDatabase();
|
db = await createMockDatabase();
|
||||||
await db.clean({ drop: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -23,7 +22,7 @@ describe('view inference', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should infer field with alias', async () => {
|
it('should infer field with alias', async () => {
|
||||||
if (db.options.dialect !== 'postgres') return;
|
if (!['postgres', 'mssql'].includes(db.options.dialect)) return;
|
||||||
|
|
||||||
const UserCollection = db.collection({
|
const UserCollection = db.collection({
|
||||||
name: 'users',
|
name: 'users',
|
||||||
@ -57,7 +56,7 @@ describe('view inference', function () {
|
|||||||
const inferredFields = await ViewFieldInference.inferFields({
|
const inferredFields = await ViewFieldInference.inferFields({
|
||||||
db,
|
db,
|
||||||
viewName,
|
viewName,
|
||||||
viewSchema: 'public',
|
viewSchema: db.options.dialect === 'mssql' ? 'dbo' : 'public',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(inferredFields['user_id_field'].source).toBe('users.id');
|
expect(inferredFields['user_id_field'].source).toBe('users.id');
|
||||||
@ -123,7 +122,7 @@ describe('view inference', function () {
|
|||||||
const inferredFields = await ViewFieldInference.inferFields({
|
const inferredFields = await ViewFieldInference.inferFields({
|
||||||
db,
|
db,
|
||||||
viewName,
|
viewName,
|
||||||
viewSchema: 'public',
|
viewSchema: db.options.dialect === 'mssql' ? 'dbo' : 'public',
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdAt = UserCollection.model.rawAttributes['createdAt'].field;
|
const createdAt = UserCollection.model.rawAttributes['createdAt'].field;
|
||||||
|
@ -918,7 +918,7 @@ export class Collection<
|
|||||||
public getTableNameWithSchemaAsString() {
|
public getTableNameWithSchemaAsString() {
|
||||||
const tableName = this.model.tableName;
|
const tableName = this.model.tableName;
|
||||||
|
|
||||||
if (this.collectionSchema() && this.db.inDialect('postgres')) {
|
if (this.collectionSchema() && (this.db.inDialect('postgres') || this.db.inDialect('mssql'))) {
|
||||||
return `${this.collectionSchema()}.${tableName}`;
|
return `${this.collectionSchema()}.${tableName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -333,19 +333,10 @@ export class EagerLoadingTree {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = params.order || orderOption(association);
|
|
||||||
if (node.model.sequelize.getDialect() === 'mssql') {
|
|
||||||
const seen = new Set();
|
|
||||||
order = order.filter((item) => {
|
|
||||||
const field = Array.isArray(item) ? item[0] : item.split(' ')[0];
|
|
||||||
return seen.has(field) ? false : seen.add(field);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const findOptions = {
|
const findOptions = {
|
||||||
where,
|
where,
|
||||||
attributes: node.attributes,
|
attributes: node.attributes,
|
||||||
order,
|
order: params.order || orderOption(association),
|
||||||
transaction,
|
transaction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -59,3 +59,4 @@ export { default as fieldTypeMap } from './view/field-type-map';
|
|||||||
|
|
||||||
export * from './view/view-inference';
|
export * from './view/view-inference';
|
||||||
export { default as QueryInterface, TableInfo } from './query-interface/query-interface';
|
export { default as QueryInterface, TableInfo } from './query-interface/query-interface';
|
||||||
|
export { OptionsParser, FieldSortOptions } from './options-parser';
|
||||||
|
@ -156,12 +156,17 @@ export class MagicAttributeModel extends Model {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!this._isAttribute(key)) {
|
if (!this._isAttribute(key)) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (key.includes('.') && this.constructor._jsonAttributes.has(key.split('.')[0])) {
|
if (this.isJsonField(key)) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const previousNestedValue = Dottie.get(this.dataValues, key);
|
const previousNestedValue = Dottie.get(this.dataValues, key);
|
||||||
if (!_.isEqual(previousNestedValue, value)) {
|
if (!_.isEqual(previousNestedValue, value)) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this._previousDataValues = _.cloneDeep(this._previousDataValues);
|
this._previousDataValues = _.cloneDeep(this._previousDataValues);
|
||||||
|
const previousValue = _.get(this.dataValues, key.split('.')[0]);
|
||||||
|
if (this.db.options.dialect === 'mssql' && _.isString(previousValue) && !_.isEmpty(previousValue)) {
|
||||||
|
// @ts-ignore
|
||||||
|
this.dataValues[key.split('.')[0]] = JSON.parse(previousValue);
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
Dottie.set(this.dataValues, key, value);
|
Dottie.set(this.dataValues, key, value);
|
||||||
this.changed(key.split('.')[0], true);
|
this.changed(key.split('.')[0], true);
|
||||||
@ -226,6 +231,25 @@ export class MagicAttributeModel extends Model {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isJsonField(key: string) {
|
||||||
|
const [column] = key.split('.');
|
||||||
|
if (!column) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.db.options.dialect === 'mssql') {
|
||||||
|
return this.getFieldByKey(column)?.type === 'json';
|
||||||
|
}
|
||||||
|
return (this.constructor as any)._jsonAttributes.has(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFieldByKey(key: string) {
|
||||||
|
const collection = this.db.modelNameCollectionMap.get(this.constructor.name);
|
||||||
|
if (!collection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return collection.getField(key);
|
||||||
|
}
|
||||||
|
|
||||||
get(key?: any, value?: any): any {
|
get(key?: any, value?: any): any {
|
||||||
if (typeof key === 'string') {
|
if (typeof key === 'string') {
|
||||||
const [column] = key.split('.');
|
const [column] = key.split('.');
|
||||||
@ -239,6 +263,10 @@ export class MagicAttributeModel extends Model {
|
|||||||
return _.get(options, key);
|
return _.get(options, key);
|
||||||
}
|
}
|
||||||
const data = super.get(key, value);
|
const data = super.get(key, value);
|
||||||
|
const previousValue = _.get(data, this.magicAttribute);
|
||||||
|
if (this.db.options.dialect === 'mssql' && _.isString(previousValue) && !_.isEmpty(previousValue)) {
|
||||||
|
_.set(data, this.magicAttribute, JSON.parse(previousValue));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
..._.omit(data, this.magicAttribute),
|
..._.omit(data, this.magicAttribute),
|
||||||
...data[this.magicAttribute],
|
...data[this.magicAttribute],
|
||||||
|
@ -14,6 +14,7 @@ import { Database } from './database';
|
|||||||
import FilterParser from './filter-parser';
|
import FilterParser from './filter-parser';
|
||||||
import { Appends, Except, FindOptions } from './repository';
|
import { Appends, Except, FindOptions } from './repository';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
const debug = require('debug')('noco-database');
|
const debug = require('debug')('noco-database');
|
||||||
|
|
||||||
@ -22,6 +23,10 @@ interface OptionsParserContext {
|
|||||||
targetKey?: string;
|
targetKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldSortOptions {
|
||||||
|
nullsLast?: (model: ModelStatic<any>, field: string) => any;
|
||||||
|
}
|
||||||
|
|
||||||
export class OptionsParser {
|
export class OptionsParser {
|
||||||
options: FindOptions;
|
options: FindOptions;
|
||||||
database: Database;
|
database: Database;
|
||||||
@ -29,6 +34,7 @@ export class OptionsParser {
|
|||||||
model: ModelStatic<any>;
|
model: ModelStatic<any>;
|
||||||
filterParser: FilterParser;
|
filterParser: FilterParser;
|
||||||
context: OptionsParserContext;
|
context: OptionsParserContext;
|
||||||
|
static fieldSortMap: Map<string, FieldSortOptions> = new Map();
|
||||||
|
|
||||||
constructor(options: FindOptions, context: OptionsParserContext) {
|
constructor(options: FindOptions, context: OptionsParserContext) {
|
||||||
const { collection } = context;
|
const { collection } = context;
|
||||||
@ -68,6 +74,10 @@ export class OptionsParser {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static registerFieldSort(dialectName: string, options: FieldSortOptions) {
|
||||||
|
this.fieldSortMap.set(dialectName, options);
|
||||||
|
}
|
||||||
|
|
||||||
isAssociation(key: string) {
|
isAssociation(key: string) {
|
||||||
return this.model.associations[key] !== undefined;
|
return this.model.associations[key] !== undefined;
|
||||||
}
|
}
|
||||||
@ -158,7 +168,12 @@ export class OptionsParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orderParams = [];
|
const orderParams = [];
|
||||||
|
// remove duplicate sort key, usage: sort = ['-id', 'id'], expect: ['-id']
|
||||||
|
const set = new Set();
|
||||||
|
sort = sort.filter((s) => {
|
||||||
|
const key = s.startsWith('-') ? s.slice(1) : s;
|
||||||
|
return !set.has(key) && set.add(key);
|
||||||
|
});
|
||||||
for (const sortKey of sort) {
|
for (const sortKey of sort) {
|
||||||
let direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
|
let direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
|
||||||
const sortField: Array<any> = sortKey.startsWith('-') ? sortKey.replace('-', '').split('.') : sortKey.split('.');
|
const sortField: Array<any> = sortKey.startsWith('-') ? sortKey.replace('-', '').split('.') : sortKey.split('.');
|
||||||
@ -189,6 +204,13 @@ export class OptionsParser {
|
|||||||
orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]);
|
orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const sortOptions = OptionsParser.fieldSortMap.get(this.database.options.dialect);
|
||||||
|
if (_.isFunction(sortOptions?.nullsLast)) {
|
||||||
|
const nullLast = sortOptions.nullsLast(this.model, sortField[0]);
|
||||||
|
if (nullLast) {
|
||||||
|
orderParams.push(nullLast);
|
||||||
|
}
|
||||||
|
}
|
||||||
orderParams.push(sortField);
|
orderParams.push(sortField);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -288,16 +288,28 @@ export class SyncRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.database.inDialect('mssql')) {
|
if (this.database.inDialect('mssql')) {
|
||||||
const constraintName = await this.sequelize.query(
|
// 先检查是否是约束
|
||||||
`SELECT name FROM sys.indexes
|
const constraintCheck = await this.sequelize.query(
|
||||||
|
`SELECT OBJECT_NAME(object_id) as name
|
||||||
|
FROM sys.indexes
|
||||||
WHERE object_id = OBJECT_ID('${this.collection.quotedTableName()}')
|
WHERE object_id = OBJECT_ID('${this.collection.quotedTableName()}')
|
||||||
AND name = '${existUniqueIndex.name}'`,
|
AND name = '${existUniqueIndex.name}'
|
||||||
{ ...options, type: 'SELECT' },
|
AND is_unique_constraint = 1`,
|
||||||
|
{
|
||||||
|
type: 'SELECT',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (constraintName?.[0] && constraintName[0]['name']) {
|
if (constraintCheck.length > 0) {
|
||||||
|
// 如果是约束,使用 DROP CONSTRAINT
|
||||||
await this.sequelize.query(
|
await this.sequelize.query(
|
||||||
`ALTER TABLE ${this.collection.quotedTableName()} DROP CONSTRAINT ${constraintName[0]['name']};`,
|
`ALTER TABLE ${this.collection.quotedTableName()} DROP CONSTRAINT ${existUniqueIndex.name};`,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 如果是普通索引,使用 DROP INDEX
|
||||||
|
await this.sequelize.query(
|
||||||
|
`DROP INDEX ${existUniqueIndex.name} ON ${this.collection.quotedTableName()};`,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -316,16 +328,32 @@ export class SyncRunner {
|
|||||||
|
|
||||||
// add unique index that not in database
|
// add unique index that not in database
|
||||||
for (const uniqueAttribute of uniqueAttributes) {
|
for (const uniqueAttribute of uniqueAttributes) {
|
||||||
// check index exists or not
|
const field = this.rawAttributes[uniqueAttribute].field;
|
||||||
const indexExists = existsUniqueIndexes.find((index) => {
|
|
||||||
return index.fields.length == 1 && index.fields[0].attribute == this.rawAttributes[uniqueAttribute].field;
|
// 检查是否存在包含该字段的索引(包括组合索引)
|
||||||
|
const hasFieldIndex = existsUniqueIndexes.some((index) => {
|
||||||
|
return index.fields[0].attribute === field;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!indexExists) {
|
if (!hasFieldIndex) {
|
||||||
await this.queryInterface.addIndex(this.tableName, [this.rawAttributes[uniqueAttribute].field], {
|
// 检查是否有重复数据
|
||||||
|
const duplicateCheck = await this.sequelize.query(
|
||||||
|
`SELECT COUNT(*) as count, ${field}
|
||||||
|
FROM ${this.collection.quotedTableName()}
|
||||||
|
GROUP BY ${field}
|
||||||
|
HAVING COUNT(*) > 1`,
|
||||||
|
{ ...options, type: 'SELECT' },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateCheck.length > 0) {
|
||||||
|
console.warn(`Cannot create unique index on ${field} due to duplicate values`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.queryInterface.addIndex(this.tableName, [field], {
|
||||||
unique: true,
|
unique: true,
|
||||||
transaction: options?.transaction,
|
transaction: options?.transaction,
|
||||||
name: `${this.collection.tableName()}_${this.rawAttributes[uniqueAttribute].field}_uk`,
|
name: `${this.collection.tableName()}_${field}_uk`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,26 @@ type InferredFieldResult = {
|
|||||||
[key: string]: InferredField;
|
[key: string]: InferredField;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InferredViewSchema = {
|
||||||
|
display?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export class ViewFieldInference {
|
export class ViewFieldInference {
|
||||||
|
static FieldTypeMap = FieldTypeMap;
|
||||||
|
|
||||||
|
static ViewSchemaConfig: Record<string, InferredViewSchema> = {
|
||||||
|
postgres: { display: true },
|
||||||
|
mysql: {},
|
||||||
|
sqlite: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
static registerFieldTypes(key: string, fieldTypes: Record<string, any>) {
|
||||||
|
ViewFieldInference.FieldTypeMap[key] = {
|
||||||
|
...(ViewFieldInference.FieldTypeMap[key] || {}),
|
||||||
|
...fieldTypes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static extractTypeFromDefinition(typeDefinition) {
|
static extractTypeFromDefinition(typeDefinition) {
|
||||||
const leftParenIndex = typeDefinition.indexOf('(');
|
const leftParenIndex = typeDefinition.indexOf('(');
|
||||||
|
|
||||||
@ -32,13 +51,17 @@ export class ViewFieldInference {
|
|||||||
return typeDefinition.substring(0, leftParenIndex).toLowerCase().trim();
|
return typeDefinition.substring(0, leftParenIndex).toLowerCase().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static registerViewSchema(key: string, config: { display?: boolean }) {
|
||||||
|
ViewFieldInference.ViewSchemaConfig[key] = config;
|
||||||
|
}
|
||||||
|
|
||||||
static async inferFields(options: {
|
static async inferFields(options: {
|
||||||
db: Database;
|
db: Database;
|
||||||
viewName: string;
|
viewName: string;
|
||||||
viewSchema?: string;
|
viewSchema?: string;
|
||||||
}): Promise<InferredFieldResult> {
|
}): Promise<InferredFieldResult> {
|
||||||
const { db } = options;
|
const { db } = options;
|
||||||
if (!db.inDialect('postgres')) {
|
if (!this.ViewSchemaConfig[db.options.dialect]?.display) {
|
||||||
options.viewSchema = undefined;
|
options.viewSchema = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,7 +162,7 @@ export class ViewFieldInference {
|
|||||||
|
|
||||||
static inferToFieldType(options: { name: string; type: string; dialect: string }) {
|
static inferToFieldType(options: { name: string; type: string; dialect: string }) {
|
||||||
const { dialect } = options;
|
const { dialect } = options;
|
||||||
const fieldTypeMap = FieldTypeMap[dialect];
|
const fieldTypeMap = this.FieldTypeMap[dialect];
|
||||||
|
|
||||||
if (!options.type) {
|
if (!options.type) {
|
||||||
return {
|
return {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user