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

Commit b11f57f3 by Jan Aagaard Meier

Merge branch 'master' of github.com:sequelize/sequelize

2 parents bb37cd30 d3dc86ee
/*
{
"globals": {
"spyOn": false,
"it": false,
"console": false,
"describe": false,
"expect": false,
"beforeEach": false,
"afterEach": false,
"waits": false,
"waitsFor": false,
"runs": false
},
"node": true,
"camelcase": true,
"curly": true,
"forin": true,
"indent": 2,
"unused": true,
"asi": true,
"evil": false,
"laxcomma": true,
"es5": true,
"quotmark": false,
"undef": true,
"strict": false
}
*/
{ {
"bitwise":false, "bitwise":false,
"boss":true, "boss":true,
......
...@@ -21,7 +21,6 @@ env: ...@@ -21,7 +21,6 @@ env:
language: node_js language: node_js
node_js: node_js:
- "0.8"
- "0.10" - "0.10"
branches: branches:
...@@ -42,4 +41,4 @@ matrix: ...@@ -42,4 +41,4 @@ matrix:
allow_failures: allow_failures:
- node_js: "0.10" - node_js: "0.10"
env: COVERAGE=true env: COVERAGE=true
\ No newline at end of file
...@@ -16,12 +16,15 @@ test: codeclimate ...@@ -16,12 +16,15 @@ test: codeclimate
else else
test: test:
@if [ "$$GREP" ]; then \ @if [ "$$GREP" ]; then \
make teaser && ./node_modules/mocha/bin/mocha --globals setImmediate,clearImmediate --check-leaks --colors -t 10000 --reporter $(REPORTER) -g "$$GREP" $(TESTS); \ make jshint && make teaser && ./node_modules/mocha/bin/mocha --globals setImmediate,clearImmediate --check-leaks --colors -t 10000 --reporter $(REPORTER) -g "$$GREP" $(TESTS); \
else \ else \
make teaser && ./node_modules/mocha/bin/mocha --globals setImmediate,clearImmediate --check-leaks --colors -t 10000 --reporter $(REPORTER) $(TESTS); \ make jshint && make teaser && ./node_modules/mocha/bin/mocha --globals setImmediate,clearImmediate --check-leaks --colors -t 10000 --reporter $(REPORTER) $(TESTS); \
fi fi
endif endif
jshint:
./node_modules/.bin/jshint lib
cover: cover:
rm -rf coverage \ rm -rf coverage \
make teaser && ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha --report lcovonly -- -t 10000 $(TESTS); \ make teaser && ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha --report lcovonly -- -t 10000 $(TESTS); \
......
...@@ -785,13 +785,18 @@ module.exports = (function() { ...@@ -785,13 +785,18 @@ module.exports = (function() {
} }
} }
} else { } else {
var primaryKeysLeft = association.associationType === 'BelongsTo' ? association.target.primaryKeyAttributes : include.association.source.primaryKeyAttributes var left = association.associationType === 'BelongsTo' ? association.target : include.association.source
, primaryKeysLeft = association.associationType === 'BelongsTo' ? left.primaryKeyAttributes : left.primaryKeyAttributes
, tableLeft = association.associationType === 'BelongsTo' ? as : parentTable , tableLeft = association.associationType === 'BelongsTo' ? as : parentTable
, attrLeft = primaryKeysLeft[0] , attrLeft = primaryKeysLeft[0]
, tableRight = association.associationType === 'BelongsTo' ? parentTable : as , tableRight = association.associationType === 'BelongsTo' ? parentTable : as
, attrRight = association.identifier , attrRight = association.identifier
, joinOn; , joinOn;
if (left.rawAttributes[attrLeft].field) {
attrLeft = left.rawAttributes[attrLeft].field;
}
// Filter statement // Filter statement
// Used by both join and subquery where // Used by both join and subquery where
joinOn = joinOn =
......
...@@ -339,90 +339,306 @@ module.exports = (function() { ...@@ -339,90 +339,306 @@ module.exports = (function() {
} }
] ]
*/ */
/*
* Assumptions
* ID is not necessarily the first field
* All fields for a level is grouped in the same set (i.e. Panel.id, Task.id, Panel.title is not possible)
* Parent keys will be seen before any include/child keys
* Previous set won't necessarily be parent set (one parent could have two children, one child would then be previous set for the other)
*/
// includeOptions are 'level'-specific where options is a general directive /*
var i = 0; * Author (MH) comment: This code is an unreadable mess, but its performant.
var groupJoinData = function(data, includeOptions, options) { * groupJoinData is a performance critical function so we prioritize perf over readability.
var results = [] */
, existingResult
, calleeData
, child
, calleeDataIgnore = ['__children']
, parseChildren = function(result) {
_.each(result.__children, function(children, key) {
result[key] = groupJoinData(children, (includeOptions.includeMap && includeOptions.includeMap[key]), options);
});
delete result.__children; var groupJoinData = function(rows, includeOptions, options) {
if (!rows.length) {
return [];
}
var
// Generic looping
i
, length
, $i
, $length
// Row specific looping
, rowsI
, rowsLength = rows.length
, row
// Key specific looping
, keys
, key
, keyI
, keyLength
, prevKey
, values
, topValues
, topExists
, previous
, checkExisting = options.checkExisting
// If we don't have to deduplicate we can pre-allocate the resulting array
, results = checkExisting ? [] : new Array(rowsLength)
, resultMap = {}
, includeMap = {}
, itemHash
, parentHash
, topHash
// Result variables for the respective functions
, $keyPrefix
, $keyPrefixString
, $prevKeyPrefixString
, $prevKeyPrefix
, $lastKeyPrefix
, $current
, $parent
// Map each key to an include option
, previousPiece
, buildIncludeMap = function (piece) {
if ($current.includeMap[piece]) {
includeMap[key] = $current = $current.includeMap[piece];
if (previousPiece) {
previousPiece = previousPiece+'.'+piece;
} else {
previousPiece = piece;
}
includeMap[previousPiece] = $current;
}
}
// Calcuate the last item in the array prefix ('Results' for 'User.Results.id')
, lastKeyPrefixMemo = {}
, lastKeyPrefix = function (key) {
if (!lastKeyPrefixMemo[key]) {
var prefix = keyPrefix(key)
, length = prefix.length;
lastKeyPrefixMemo[key] = !length ? '' : prefix[length - 1];
}
return lastKeyPrefixMemo[key];
}
// Calculate the string prefix of a key ('User.Results' for 'User.Results.id')
, keyPrefixStringMemo = {}
, keyPrefixString = function (key, memo) {
if (!memo[key]) {
memo[key] = key.substr(0, key.lastIndexOf('.'));
}
return memo[key];
} }
, primaryKeyAttribute // Removes the prefix from a key ('id' for 'User.Results.id')
, primaryKeyMap = {}; , removeKeyPrefixMemo = {}
, removeKeyPrefix = function (key) {
if (!removeKeyPrefixMemo[key]) {
var index = key.lastIndexOf('.');
removeKeyPrefixMemo[key] = key.substr(index === -1 ? 0 : index + 1);
}
return removeKeyPrefixMemo[key];
}
// Calculates the array prefix of a key (['User', 'Results'] for 'User.Results.id')
, keyPrefixMemo = {}
, keyPrefix = function (key) {
// We use a double memo and keyPrefixString so that different keys with the same prefix will receive the same array instead of differnet arrays with equal values
if (!keyPrefixMemo[key]) {
var prefixString = keyPrefixString(key, keyPrefixStringMemo);
if (!keyPrefixMemo[prefixString]) {
keyPrefixMemo[prefixString] = prefixString ? prefixString.split(".") : [];
}
keyPrefixMemo[key] = keyPrefixMemo[prefixString];
}
return keyPrefixMemo[key];
}
, primaryKeyAttributes
, prefix;
// Ignore all include keys on main data for (rowsI = 0; rowsI < rowsLength; rowsI++) {
if (includeOptions.includeNames) { row = rows[rowsI];
calleeDataIgnore = calleeDataIgnore.concat(includeOptions.includeNames);
} // Keys are the same for all rows, so only need to compute them on the first row
if (includeOptions.model.primaryKeyAttributes.length === 1) { if (rowsI === 0) {
primaryKeyAttribute = includeOptions.model.primaryKeyAttribute; keys = Object.keys(row);
} keyLength = keys.length;
}
data.forEach(function parseRow(row) { if (checkExisting) {
row = Dot.transform(row); topExists = false;
calleeData = row;
// If there are :M associations included we need to see if the main result of the row has already been identified // Compute top level hash key (this is usually just the primary key values)
if (options.checkExisting) { $length = includeOptions.model.primaryKeyAttributes.length;
if (primaryKeyAttribute) { if ($length === 1) {
// If we can, detect equality on the singular primary key topHash = row[includeOptions.model.primaryKeyAttributes[0]];
existingResult = primaryKeyMap[calleeData[primaryKeyAttribute]];
} else { } else {
// If we can't identify on a singular primary key, do a full row equality check topHash = '';
existingResult = _.find(results, function(result) { for ($i = 0; $i < $length; $i++) {
return Utils._.isEqual(_.omit(result, calleeDataIgnore), calleeData); topHash += row[includeOptions.model.primaryKeyAttributes[$i]];
}); }
} }
} else {
existingResult = null;
} }
if (!existingResult) { topValues = values = {};
results.push(existingResult = calleeData); $prevKeyPrefix = undefined;
if (options.checkExisting && primaryKeyAttribute) { for (keyI = 0; keyI < keyLength; keyI++) {
primaryKeyMap[existingResult[primaryKeyAttribute]] = existingResult; key = keys[keyI];
// The string prefix isn't actualy needed
// We use it so keyPrefix for different keys will resolve to the same array if they have the same prefix
// TODO: Find a better way?
$keyPrefixString = keyPrefixString(key, keyPrefixStringMemo);
$keyPrefix = keyPrefix(key);
// On the first row we compute the includeMap
if (rowsI === 0 && includeMap[key] === undefined) {
if (!$keyPrefix.length) {
includeMap[key] = includeMap[''] = includeOptions;
} else {
$current = includeOptions;
previousPiece = undefined;
$keyPrefix.forEach(buildIncludeMap);
}
} }
}
for (var attrName in row) { // End of key set
if (row.hasOwnProperty(attrName)) { if ($prevKeyPrefix !== undefined && $prevKeyPrefix !== $keyPrefix) {
// Child if object, and is an child include if (checkExisting) {
child = Object(row[attrName]) === row[attrName] && includeOptions.includeMap && includeOptions.includeMap[attrName]; // Compute hash key for this set instance
// TODO: Optimize
length = $prevKeyPrefix.length;
$parent = null;
parentHash = null;
if (length) {
for (i = 0; i < length; i++) {
prefix = $parent ? $parent+'.'+$prevKeyPrefix[i] : $prevKeyPrefix[i];
primaryKeyAttributes = includeMap[prefix].model.primaryKeyAttributes;
$length = primaryKeyAttributes.length;
if ($length === 1) {
itemHash = prefix+row[prefix+'.'+primaryKeyAttributes[0]];
} else {
itemHash = prefix;
for ($i = 0; $i < $length; $i++) {
itemHash += row[prefix+'.'+primaryKeyAttributes[$i]];
}
}
if (!parentHash) {
parentHash = topHash;
}
itemHash = parentHash + itemHash;
$parent = prefix;
if (i < length - 1) {
parentHash = itemHash;
}
}
} else {
itemHash = topHash;
}
if (child) { if (itemHash === topHash) {
// Make sure nested object is available if (!resultMap[itemHash]) {
if (!existingResult.__children) { resultMap[itemHash] = values;
existingResult.__children = {}; } else {
topExists = true;
}
} else {
if (!resultMap[itemHash]) {
$parent = resultMap[parentHash];
$lastKeyPrefix = lastKeyPrefix(prevKey);
//console.log($parent, prevKey, $lastKeyPrefix);
if (includeMap[prevKey].association.isSingleAssociation) {
$parent[$lastKeyPrefix] = resultMap[itemHash] = values;
} else {
if (!$parent[$lastKeyPrefix]) {
$parent[$lastKeyPrefix] = [];
}
$parent[$lastKeyPrefix].push(resultMap[itemHash] = values);
}
}
} }
if (!existingResult.__children[attrName]) {
existingResult.__children[attrName] = []; // Reset values
values = {};
} else {
// If checkExisting is false it's because there's only 1:1 associations in this query
// However we still need to map onto the appropriate parent
// For 1:1 we map forward, initializing the value object on the parent to be filled in the next iterations of the loop
$current = topValues;
length = $keyPrefix.length;
if (length) {
for (i = 0; i < length; i++) {
if (i === length -1) {
values = $current[$keyPrefix[i]] = {};
}
$current = $current[$keyPrefix[i]];
}
} }
}
}
existingResult.__children[attrName].push(row[attrName]); // End of iteration, set value and set prev values (for next iteration)
values[removeKeyPrefix(key)] = row[key];
prevKey = key;
$prevKeyPrefix = $keyPrefix;
$prevKeyPrefixString = $keyPrefixString;
}
// Remove from main if (checkExisting) {
delete existingResult[attrName]; length = $prevKeyPrefix.length;
$parent = null;
parentHash = null;
if (length) {
for (i = 0; i < length; i++) {
prefix = $parent ? $parent+'.'+$prevKeyPrefix[i] : $prevKeyPrefix[i];
primaryKeyAttributes = includeMap[prefix].model.primaryKeyAttributes;
$length = primaryKeyAttributes.length;
if ($length === 1) {
itemHash = prefix+row[prefix+'.'+primaryKeyAttributes[0]];
} else {
itemHash = prefix;
for ($i = 0; $i < $length; $i++) {
itemHash += row[prefix+'.'+primaryKeyAttributes[$i]];
}
}
if (!parentHash) {
parentHash = topHash;
}
itemHash = parentHash + itemHash;
$parent = prefix;
if (i < length - 1) {
parentHash = itemHash;
}
} }
} else {
itemHash = topHash;
} }
}
// parseChildren in same loop if no duplicate values are possible if (itemHash === topHash) {
if (!options.checkExisting) { if (!resultMap[itemHash]) {
parseChildren(existingResult); resultMap[itemHash] = values;
} else {
topExists = true;
}
} else {
if (!resultMap[itemHash]) {
$parent = resultMap[parentHash];
$lastKeyPrefix = lastKeyPrefix(prevKey);
//console.log($parent, prevKey, $lastKeyPrefix);
if (includeMap[prevKey].association.isSingleAssociation) {
$parent[$lastKeyPrefix] = resultMap[itemHash] = values;
} else {
if (!$parent[$lastKeyPrefix]) {
$parent[$lastKeyPrefix] = [];
}
$parent[$lastKeyPrefix].push(resultMap[itemHash] = values);
}
}
}
if (!topExists) {
results.push(topValues);
}
} else {
results[rowsI] = topValues;
} }
});
// parseChildren after row parsing if duplicate values are possible
if (options.checkExisting) {
results.forEach(parseChildren);
} }
return results; return results;
......
...@@ -362,8 +362,8 @@ module.exports = (function() { ...@@ -362,8 +362,8 @@ module.exports = (function() {
, self = this , self = this
, accessor = Utils._.camelize(key) , accessor = Utils._.camelize(key)
, childOptions , childOptions
, primaryKeyAttribute = include.model.primaryKeyAttribute , primaryKeyAttribute = include.model.primaryKeyAttribute
, isEmpty = value[0] && value[0][primaryKeyAttribute] === null; , isEmpty;
if (!isEmpty) { if (!isEmpty) {
childOptions = { childOptions = {
...@@ -382,9 +382,15 @@ module.exports = (function() { ...@@ -382,9 +382,15 @@ module.exports = (function() {
accessor = accessor.slice(0, 1).toLowerCase() + accessor.slice(1); accessor = accessor.slice(0, 1).toLowerCase() + accessor.slice(1);
if (association.isSingleAssociation) { if (association.isSingleAssociation) {
if (Array.isArray(value)) {
value = value[0];
}
isEmpty = value && value[primaryKeyAttribute] === null;
accessor = Utils.singularize(accessor, self.Model.options.language); accessor = Utils.singularize(accessor, self.Model.options.language);
self[accessor] = self.dataValues[accessor] = isEmpty ? null : include.model.build(value[0], childOptions); self[accessor] = self.dataValues[accessor] = isEmpty ? null : include.model.build(value, childOptions);
} else { } else {
isEmpty = value[0] && value[0][primaryKeyAttribute] === null;
self[accessor] = self.dataValues[accessor] = isEmpty ? [] : include.model.bulkBuild(value, childOptions); self[accessor] = self.dataValues[accessor] = isEmpty ? [] : include.model.bulkBuild(value, childOptions);
} }
} }
...@@ -506,6 +512,16 @@ module.exports = (function() { ...@@ -506,6 +512,16 @@ module.exports = (function() {
} else { } else {
var identifier = self.primaryKeyValues; var identifier = self.primaryKeyValues;
if (identifier) {
for (var attrName in identifier) {
// Field name mapping
if (self.Model.rawAttributes[attrName].field) {
identifier[self.Model.rawAttributes[attrName].field] = identifier[attrName];
delete identifier[attrName];
}
}
}
if (identifier === null && self.__options.whereCollection !== null) { if (identifier === null && self.__options.whereCollection !== null) {
identifier = self.__options.whereCollection; identifier = self.__options.whereCollection;
} }
......
...@@ -1230,8 +1230,17 @@ module.exports = (function() { ...@@ -1230,8 +1230,17 @@ module.exports = (function() {
} }
}); });
// Map attributes for serial identification
var attributes = {};
for (var attr in self.rawAttributes) {
attributes[attr] = self.rawAttributes[attr];
if (self.rawAttributes[attr].field) {
attributes[self.rawAttributes[attr].field] = self.rawAttributes[attr];
}
}
// Insert all records at once // Insert all records at once
return self.QueryInterface.bulkInsert(self.getTableName(), records, options, self).then(runAfterCreate); return self.QueryInterface.bulkInsert(self.getTableName(), records, options, attributes).then(runAfterCreate);
} else { } else {
// Records were already saved while running create / update hooks // Records were already saved while running create / update hooks
return runAfterCreate(); return runAfterCreate();
...@@ -1461,6 +1470,9 @@ module.exports = (function() { ...@@ -1461,6 +1470,9 @@ module.exports = (function() {
var mapFieldNames = function(options, Model) { var mapFieldNames = function(options, Model) {
if (options.attributes) { if (options.attributes) {
options.attributes = options.attributes.map(function(attr) { options.attributes = options.attributes.map(function(attr) {
// Object lookups will force any variable to strings, we don't want that for special objects etc
if (typeof attr !== "string") return attr;
// Map attributes to aliased syntax attributes
if (Model.rawAttributes[attr] && Model.rawAttributes[attr].field) { if (Model.rawAttributes[attr] && Model.rawAttributes[attr].field) {
return [Model.rawAttributes[attr].field, attr]; return [Model.rawAttributes[attr].field, attr];
} }
...@@ -1470,9 +1482,11 @@ module.exports = (function() { ...@@ -1470,9 +1482,11 @@ module.exports = (function() {
if (options.where) { if (options.where) {
for (var attr in options.where) { for (var attr in options.where) {
if (Model.rawAttributes[attr] && Model.rawAttributes[attr].field) { if (typeof attr === "string") {
options.where[Model.rawAttributes[attr].field] = options.where[attr]; if (Model.rawAttributes[attr] && Model.rawAttributes[attr].field) {
delete options.where[attr]; options.where[Model.rawAttributes[attr].field] = options.where[attr];
delete options.where[attr];
}
} }
} }
} }
......
...@@ -423,8 +423,8 @@ module.exports = (function() { ...@@ -423,8 +423,8 @@ module.exports = (function() {
}); });
}; };
QueryInterface.prototype.bulkInsert = function(tableName, records, options, Model) { QueryInterface.prototype.bulkInsert = function(tableName, records, options, attributes) {
var sql = this.QueryGenerator.bulkInsertQuery(tableName, records, options, Model.rawAttributes); var sql = this.QueryGenerator.bulkInsertQuery(tableName, records, options, attributes);
return this.sequelize.query(sql, null, options); return this.sequelize.query(sql, null, options);
}; };
......
...@@ -71,7 +71,8 @@ ...@@ -71,7 +71,8 @@
"async": "~0.2.10", "async": "~0.2.10",
"coffee-script": "~1.7.1", "coffee-script": "~1.7.1",
"markdox": "0.1.4", "markdox": "0.1.4",
"sinon-chai": "~2.5.0" "sinon-chai": "~2.5.0",
"jshint": ">=2.4.2"
}, },
"keywords": [ "keywords": [
"mysql", "mysql",
......
...@@ -7,10 +7,10 @@ var chai = require('chai') ...@@ -7,10 +7,10 @@ var chai = require('chai')
, Support = require(__dirname + '/../support') , Support = require(__dirname + '/../support')
, DataTypes = require(__dirname + "/../../lib/data-types") , DataTypes = require(__dirname + "/../../lib/data-types")
, dialect = Support.getTestDialect() , dialect = Support.getTestDialect()
, datetime = require('chai-datetime') , datetime = require('chai-datetime');
chai.use(datetime) chai.use(datetime);
chai.config.includeStack = true chai.config.includeStack = true;
describe(Support.getTestDialectTeaser("Model"), function () { describe(Support.getTestDialectTeaser("Model"), function () {
describe('attributes', function () { describe('attributes', function () {
...@@ -19,6 +19,13 @@ describe(Support.getTestDialectTeaser("Model"), function () { ...@@ -19,6 +19,13 @@ describe(Support.getTestDialectTeaser("Model"), function () {
var queryInterface = this.sequelize.getQueryInterface(); var queryInterface = this.sequelize.getQueryInterface();
this.User = this.sequelize.define('user', { this.User = this.sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true,
field: 'userId'
},
name: { name: {
type: DataTypes.STRING, type: DataTypes.STRING,
field: 'full_name' field: 'full_name'
...@@ -54,7 +61,7 @@ describe(Support.getTestDialectTeaser("Model"), function () { ...@@ -54,7 +61,7 @@ describe(Support.getTestDialectTeaser("Model"), function () {
return Promise.all([ return Promise.all([
queryInterface.createTable('users', { queryInterface.createTable('users', {
id: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
primaryKey: true, primaryKey: true,
...@@ -78,7 +85,7 @@ describe(Support.getTestDialectTeaser("Model"), function () { ...@@ -78,7 +85,7 @@ describe(Support.getTestDialectTeaser("Model"), function () {
type: DataTypes.STRING type: DataTypes.STRING
} }
}) })
]) ]);
}); });
it('should create, fetch and update with alternative field names from a simple model', function () { it('should create, fetch and update with alternative field names from a simple model', function () {
...@@ -101,7 +108,7 @@ describe(Support.getTestDialectTeaser("Model"), function () { ...@@ -101,7 +108,7 @@ describe(Support.getTestDialectTeaser("Model"), function () {
}); });
}).then(function (user) { }).then(function (user) {
expect(user.get('name')).to.equal('Barfoo'); expect(user.get('name')).to.equal('Barfoo');
}) });
}); });
it('should work with attributes and where on includes', function () { it('should work with attributes and where on includes', function () {
...@@ -139,8 +146,8 @@ describe(Support.getTestDialectTeaser("Model"), function () { ...@@ -139,8 +146,8 @@ describe(Support.getTestDialectTeaser("Model"), function () {
} }
}); });
}).then(function (user) { }).then(function (user) {
expect(user).to.be.ok expect(user).to.be.ok;
}) });
}); });
it('should work with bulkCreate and findAll', function () { it('should work with bulkCreate and findAll', function () {
...@@ -155,17 +162,17 @@ describe(Support.getTestDialectTeaser("Model"), function () { ...@@ -155,17 +162,17 @@ describe(Support.getTestDialectTeaser("Model"), function () {
return self.User.findAll(); return self.User.findAll();
}).then(function (users) { }).then(function (users) {
users.forEach(function (user) { users.forEach(function (user) {
expect(['Abc', 'Bcd', 'Cde'].indexOf(user.get('name')) !== -1).to.be.true expect(['Abc', 'Bcd', 'Cde'].indexOf(user.get('name')) !== -1).to.be.true;
}); });
}); });
}); });
}) });
describe('types', function () { describe('types', function () {
describe('VIRTUAL', function () { describe('VIRTUAL', function () {
it('should be ignored in create, updateAttributes and find'); it('should be ignored in create, updateAttributes and find');
it('should be ignored in bulkCreate and findAll'); it('should be ignored in bulkCreate and findAll');
}) });
}) });
}) });
}) });
\ No newline at end of file \ No newline at end of file
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!