Skip to content

Commit

Permalink
Fixes columnNameMappers applied to json column when using FieldExpres…
Browse files Browse the repository at this point in the history
…sions (Vincit#1089)
  • Loading branch information
cesumilo committed Mar 15, 2024
1 parent f0c3bf5 commit a8c937f
Show file tree
Hide file tree
Showing 12 changed files with 190 additions and 14 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ profiling
npm-debug.log*
.vscode
/**/yarn.lock
/**/.vuepress/dist
/**/.vuepress/dist
docker-compose.override.yml
23 changes: 21 additions & 2 deletions lib/model/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,26 @@ class Model {
const columnNameMappers = this.constructor.getColumnNameMappers();

if (columnNameMappers) {
json = columnNameMappers.parse(json);
json = columnNameMappers.parse(json, this.isJsonColumn(this.constructor.$$columnInfo));
}

return parseJsonAttributes(json, this.constructor);
}

isJsonColumn(columnInfo) {
return (columnName) =>
typeof columnInfo == 'object' &&
columnInfo.hasOwnProperty(columnName) &&
(columnInfo[columnName]['type'] == 'json' || columnInfo[columnName]['type'] == 'jsonb');
}

$formatDatabaseJson(json) {
const columnNameMappers = this.constructor.getColumnNameMappers();

json = formatJsonAttributes(json, this.constructor);

if (columnNameMappers) {
json = columnNameMappers.format(json);
json = columnNameMappers.format(json, this.isJsonColumn(this.constructor.$$columnInfo));
}

return json;
Expand Down Expand Up @@ -405,6 +412,10 @@ class Model {
return this.modifiers || {};
}

static async columnInfo(creator) {
return await cachedGetAsync(this, '$$columnInfo', creator);
}

static columnNameToPropertyName(columnName) {
let colToProp = cachedGet(this, '$$colToProp', () => new Map());
let propertyName = colToProp.get(columnName);
Expand Down Expand Up @@ -816,6 +827,14 @@ function relatedQuery({ modelClass, relationName, transaction, alwaysReturnArray
});
}

async function cachedGetAsync(target, hiddenPropertyName, creator) {
if (!target.hasOwnProperty(hiddenPropertyName)) {
defineNonEnumerableProperty(target, hiddenPropertyName, await creator(target));
}

return target[hiddenPropertyName];
}

function cachedGet(target, hiddenPropertyName, creator) {
if (!target.hasOwnProperty(hiddenPropertyName)) {
defineNonEnumerableProperty(target, hiddenPropertyName, creator(target));
Expand Down
1 change: 1 addition & 0 deletions lib/model/modelUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const staticHiddenProps = [
'$$readOnlyAttributes',
'$$idRelationProperty',
'$$virtualAttributes',
'$$columnInfo',
];

function defineNonEnumerableProperty(obj, prop, value) {
Expand Down
4 changes: 3 additions & 1 deletion lib/queryBuilder/QueryBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,11 @@ class QueryBuilder extends QueryBuilderBase {
async execute() {
// Take a clone so that we don't modify this instance during execution.
const builder = this.clone();
const colBuilder = this.clone();

try {
// Lazy fetch columns info (fixes #1089)
await builder._modelClass.columnInfo(async (_) => await colBuilder.columnInfo());
await beforeExecute(builder);
const result = await doExecute(builder);
return await afterExecute(builder, result);
Expand Down Expand Up @@ -1097,7 +1100,6 @@ function doExecute(builder) {
promise = Promise.resolve(queryExecutorOperation.queryExecutor(builder));
} else {
promise = Promise.resolve(buildKnexQuery(builder));

promise = chainOperationHooks(promise, builder, 'onRawResult');
promise = promise.then((result) => createModels(result, builder));
}
Expand Down
14 changes: 12 additions & 2 deletions lib/utils/identifierMapping.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function mapLastPart(mapper, separator) {
// Returns a function that takes an object as an input and maps the object's keys
// using `mapper`. If the input is not an object, the input is returned unchanged.
function keyMapper(mapper) {
return (obj) => {
return (obj, isJsonColumn = () => false) => {
if (!isObject(obj) || Array.isArray(obj)) {
return obj;
}
Expand All @@ -155,7 +155,17 @@ function keyMapper(mapper) {

for (let i = 0, l = keys.length; i < l; ++i) {
const key = keys[i];
out[mapper(key)] = obj[key];

if (key.indexOf(':') > -1) {
const parts = key.split(':');
if (isJsonColumn(mapper(parts[0]))) {
out[`${mapper(parts[0])}:${parts[1]}`] = obj[key];
} else {
out[mapper(key)] = obj[key];
}
} else {
out[mapper(key)] = obj[key];
}
}

return out;
Expand Down
8 changes: 7 additions & 1 deletion tests/integration/graph/GraphInsert.js
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,13 @@ module.exports = (session) => {

function createModels() {
mockKnex = mockKnexFactory(session.knex, function (_, oldImpl, args) {
++numExecutedQueries;
const queryString = this.toSQL().sql;
if (
queryString.match(/select \* from information_schema\.columns/) == null &&
queryString.match(/PRAGMA/) == null
) {
++numExecutedQueries;
}
return oldImpl.apply(this, args);
});

Expand Down
58 changes: 58 additions & 0 deletions tests/integration/misc/#1089.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const _ = require('lodash');
const expect = require('expect.js');
const { Model, snakeCaseMappers } = require('../../../');

module.exports = (session) => {
describe("Objection shouldn't touch JSON fields using columnNameMappers and FieldExpressions (#1089)", () => {
class A extends Model {
static get tableName() {
return 'a';
}

static get columnNameMappers() {
return snakeCaseMappers();
}
}

beforeEach(() => {
return session.knex.schema
.dropTableIfExists('a')
.createTable('a', (table) => {
table.integer('id').primary();
table.jsonb('my_json').nullable().defaultTo(null);
})
.then(() => {
return Promise.all([
session.knex('a').insert({
id: 1,
my_json: JSON.stringify([{ innerKey: 2 }]),
}),
]);
});
});

afterEach(() => {
return session.knex.schema.dropTableIfExists('a');
});

it("json field keys aren't modified with columnNameMappers with FieldExpressions", () => {
if (!session.isPostgres()) {
// Note(cesumilo): Only working on postgresql.
return expect(true).to.eql(true);
}

return A.query(session.knex)
.patch({
'myJson:[0][innerKey]': 1,
})
.then((result) => {
expect(result).to.eql(1);
return A.query(session.knex);
})
.then((results) => {
expect(results[0].id).to.eql(1);
expect(results[0].myJson[0].innerKey).to.eql(1);
});
});
});
};
24 changes: 21 additions & 3 deletions tests/integration/upsertGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,13 @@ module.exports = (session) => {

// Wrap the transaction to catch the executed sql.
trx = mockKnexFactory(trx, function (mock, oldImpl, args) {
sql.push(this.toString());
const queryString = this.toString();
if (
queryString.match(/PRAGMA/) == null &&
queryString.match(/select \* from information_schema\.columns/) == null
) {
sql.push(queryString);
}
return oldImpl.apply(this, args);
});

Expand Down Expand Up @@ -710,7 +716,13 @@ module.exports = (session) => {

// Wrap the transaction to catch the executed sql.
trx = mockKnexFactory(trx, function (mock, oldImpl, args) {
sql.push(this.toString());
const queryString = this.toString();
if (
queryString.match(/select \* from information_schema\.columns/) == null &&
queryString.match(/PRAGMA/) == null
) {
sql.push(queryString);
}
return oldImpl.apply(this, args);
});

Expand Down Expand Up @@ -2668,7 +2680,13 @@ module.exports = (session) => {

// Wrap the transaction to catch the executed sql.
trx = mockKnexFactory(trx, function (mock, oldImpl, args) {
sql.push(this.toString());
const queryString = this.toString();
if (
queryString.match(/select \* from information_schema\.columns/) == null &&
queryString.match(/PRAGMA/) == null
) {
sql.push(queryString);
}
return oldImpl.apply(this, args);
});

Expand Down
18 changes: 17 additions & 1 deletion tests/unit/queryBuilder/QueryBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@ describe('QueryBuilder', () => {
let executedQueries = [];
let mockKnex = null;
let TestModel = null;
let keepColumnInfo = false;
let skippedColumnInfo = 0;

before(() => {
let knex = Knex({ client: 'pg' });

mockKnex = knexMocker(knex, function (mock, oldImpl, args) {
executedQueries.push(this.toString());
const queryString = this.toString();
const matchColInfo = queryString.match(/select \* from information_schema\.columns/) != null;

if (matchColInfo && keepColumnInfo && skippedColumnInfo++ > 0) {
executedQueries.push(queryString);
} else if (!matchColInfo) {
executedQueries.push(queryString);
} else {
const promise = Promise.resolve([]);
return promise.then.apply(promise, args);
}

let result = mockKnexQueryResults[mockKnexQueryResultIndex++] || [];
let promise = Promise.resolve(result);
Expand All @@ -35,6 +47,8 @@ describe('QueryBuilder', () => {
mockKnexQueryResults = [];
mockKnexQueryResultIndex = 0;
executedQueries = [];
keepColumnInfo = false;
skippedColumnInfo = 0;

TestModel = class TestModel extends Model {
static get tableName() {
Expand Down Expand Up @@ -925,6 +939,8 @@ describe('QueryBuilder', () => {
});

it('should consider withSchema when looking for column info', (done) => {
keepColumnInfo = true;

class TestModelRelated extends Model {
static get tableName() {
return 'Related';
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/relations/BelongsToOneRelation.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,24 @@ describe('BelongsToOneRelation', () => {
let relation;
let compositeKeyRelation;

let keepColumnInfo = false;
let skippedColumnInfo = 0;

before(() => {
let knex = Knex({ client: 'pg' });

mockKnex = knexMocker(knex, function (mock, oldImpl, args) {
executedQueries.push(this.toString());
const queryString = this.toString();
const matchColInfo = queryString.match(/select \* from information_schema\.columns/) != null;

if (matchColInfo && keepColumnInfo && skippedColumnInfo++ > 0) {
executedQueries.push(queryString);
} else if (!matchColInfo) {
executedQueries.push(queryString);
} else {
const promise = Promise.resolve([]);
return promise.then.apply(promise, args);
}

let result = mockKnexQueryResults.shift() || [];
let promise = Promise.resolve(result);
Expand All @@ -36,6 +49,8 @@ describe('BelongsToOneRelation', () => {
beforeEach(() => {
mockKnexQueryResults = [];
executedQueries = [];
keepColumnInfo = false;
skippedColumnInfo = 0;

OwnerModel = class OwnerModel extends Model {
static get tableName() {
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/relations/HasManyRelation.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,24 @@ describe('HasManyRelation', () => {
let relation;
let compositeKeyRelation;

let keepColumnInfo = false;
let skippedColumnInfo = 0;

before(() => {
let knex = Knex({ client: 'pg' });

mockKnex = knexMocker(knex, function (mock, oldImpl, args) {
executedQueries.push(this.toString());
const queryString = this.toString();
const matchColInfo = queryString.match(/select \* from information_schema\.columns/) != null;

if (matchColInfo && keepColumnInfo && skippedColumnInfo++ > 0) {
executedQueries.push(queryString);
} else if (!matchColInfo) {
executedQueries.push(queryString);
} else {
const promise = Promise.resolve([]);
return promise.then.apply(promise, args);
}

let result = mockKnexQueryResults.shift() || [];
let promise = Promise.resolve(result);
Expand All @@ -36,6 +49,8 @@ describe('HasManyRelation', () => {
beforeEach(() => {
mockKnexQueryResults = [];
executedQueries = [];
keepColumnInfo = false;
skippedColumnInfo = 0;

OwnerModel = class OwnerModel extends Model {
static get tableName() {
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/relations/ManyToManyRelation.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,24 @@ describe('ManyToManyRelation', () => {
let relation;
let compositeKeyRelation;

let keepColumnInfo = false;
let skippedColumnInfo = 0;

beforeEach(() => {
let knex = Knex({ client: 'pg' });

mockKnex = knexMocker(knex, function (mock, oldImpl, args) {
executedQueries.push(this.toString());
const queryString = this.toString();
const matchColInfo = queryString.match(/select \* from information_schema\.columns/) != null;

if (matchColInfo && keepColumnInfo && skippedColumnInfo++ > 0) {
executedQueries.push(queryString);
} else if (!matchColInfo) {
executedQueries.push(queryString);
} else {
const promise = Promise.resolve([]);
return promise.then.apply(promise, args);
}

let result = mockKnexQueryResults.shift() || [];
let promise = Promise.resolve(result);
Expand All @@ -39,6 +52,8 @@ describe('ManyToManyRelation', () => {
beforeEach(() => {
mockKnexQueryResults = [];
executedQueries = [];
keepColumnInfo = false;
skippedColumnInfo = 0;

OwnerModel = class OwnerModel extends Model {
static get tableName() {
Expand Down

0 comments on commit a8c937f

Please sign in to comment.