made simple one-time move work

This commit is contained in:
Kevin Jahns 2021-12-06 13:23:01 +01:00
parent d314c3e1a6
commit fc5e36158f
7 changed files with 258 additions and 102 deletions

View File

@ -1,6 +1,7 @@
import * as error from 'lib0/error' import * as error from 'lib0/error'
import * as decoding from 'lib0/decoding' import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import { import {
AbstractType, ContentType, ID, RelativePosition, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore, getItem, getItemCleanStart, getItemCleanEnd // eslint-disable-line AbstractType, ContentType, ID, RelativePosition, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore, getItem, getItemCleanStart, getItemCleanEnd // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@ -8,7 +9,7 @@ import {
/** /**
* @param {ContentMove} moved * @param {ContentMove} moved
* @param {Transaction} tr * @param {Transaction} tr
* @return {{ start: Item | null, end: Item | null }} $start (inclusive) is the beginning and $end (exclusive) is the end of the moved area * @return {{ start: Item, end: Item | null }} $start (inclusive) is the beginning and $end (exclusive) is the end of the moved area
*/ */
export const getMovedCoords = (moved, tr) => { export const getMovedCoords = (moved, tr) => {
let start // this (inclusive) is the beginning of the moved area let start // this (inclusive) is the beginning of the moved area
@ -37,16 +38,21 @@ export const getMovedCoords = (moved, tr) => {
} else { } else {
end = null end = null
} }
return { start, end } return { start: /** @type {Item} */ (start), end }
} }
/** /**
* @todo remove this if not needed
*
* @param {ContentMove} moved * @param {ContentMove} moved
* @param {Item} movedItem * @param {Item} movedItem
* @param {Transaction} tr * @param {Transaction} tr
* @param {function(Item):void} cb * @param {function(Item):void} cb
*/ */
export const iterateMoved = (moved, movedItem, tr, cb) => { export const iterateMoved = (moved, movedItem, tr, cb) => {
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(moved, tr) let { start, end } = getMovedCoords(moved, tr)
while (start !== end && start != null) { while (start !== end && start != null) {
if (!start.deleted) { if (!start.deleted) {
@ -74,6 +80,9 @@ export const findMoveLoop = (moved, movedItem, trackedMovedItems, tr) => {
return true return true
} }
trackedMovedItems.add(movedItem) trackedMovedItems.add(movedItem)
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(moved, tr) let { start, end } = getMovedCoords(moved, tr)
while (start !== end && start != null) { while (start !== end && start != null) {
if (start.deleted && start.moved === movedItem && start.content.constructor === ContentMove) { if (start.deleted && start.moved === movedItem && start.content.constructor === ContentMove) {
@ -162,6 +171,9 @@ export class ContentMove {
*/ */
integrate (transaction, item) { integrate (transaction, item) {
/** @type {AbstractType<any>} */ (item.parent)._searchMarker = [] /** @type {AbstractType<any>} */ (item.parent)._searchMarker = []
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(this, transaction) let { start, end } = getMovedCoords(this, transaction)
while (start !== end && start != null) { while (start !== end && start != null) {
if (!start.deleted) { if (!start.deleted) {
@ -184,6 +196,9 @@ export class ContentMove {
* @param {Item} item * @param {Item} item
*/ */
delete (transaction, item) { delete (transaction, item) {
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(this, transaction) let { start, end } = getMovedCoords(this, transaction)
while (start !== end && start != null) { while (start !== end && start != null) {
if (start.moved === item) { if (start.moved === item) {
@ -218,6 +233,7 @@ export class ContentMove {
write (encoder, offset) { write (encoder, offset) {
encoder.writeAny(this.start) encoder.writeAny(this.start)
encoder.writeAny(this.end) encoder.writeAny(this.end)
encoding.writeVarUint(encoder.restEncoder, this.priority)
} }
/** /**

View File

@ -36,7 +36,7 @@ const maxSearchMarker = 80
*/ */
export const useSearchMarker = (tr, yarray, index, f) => { export const useSearchMarker = (tr, yarray, index, f) => {
const searchMarker = yarray._searchMarker const searchMarker = yarray._searchMarker
if (searchMarker === null || yarray._start === null || index < 30) { if (searchMarker === null || yarray._start === null || index < 5) {
return f(new ListIterator(yarray).forward(tr, index)) return f(new ListIterator(yarray).forward(tr, index))
} }
if (searchMarker.length === 0) { if (searchMarker.length === 0) {
@ -48,26 +48,27 @@ export const useSearchMarker = (tr, yarray, index, f) => {
const sm = searchMarker.reduce( const sm = searchMarker.reduce(
(a, b, arrayIndex) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b (a, b, arrayIndex) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b
) )
const createFreshMarker = searchMarker.length < maxSearchMarker && math.abs(sm.index - index) > 30 const newIsCheaper = math.abs(sm.index - index) > index
const fsm = createFreshMarker ? sm.clone() : sm const createFreshMarker = searchMarker.length < maxSearchMarker && (math.abs(sm.index - index) > 5 || newIsCheaper)
const fsm = createFreshMarker ? (newIsCheaper ? new ListIterator(yarray) : sm.clone()) : sm
const prevItem = /** @type {Item} */ (sm.nextItem) const prevItem = /** @type {Item} */ (sm.nextItem)
if (createFreshMarker) { if (createFreshMarker) {
searchMarker.push(fsm) searchMarker.push(fsm)
} }
const diff = fsm.index - index const diff = fsm.index - index
// @todo create fresh marker if diff > index
if (diff > 0) { if (diff > 0) {
fsm.backward(tr, diff) fsm.backward(tr, diff)
} else { } else {
fsm.forward(tr, -diff) fsm.forward(tr, -diff)
} }
// @todo remove this tests // @todo remove this tests
/*
const otherTesting = new ListIterator(yarray) const otherTesting = new ListIterator(yarray)
otherTesting.forward(tr, index) otherTesting.forward(tr, index)
if (otherTesting.nextItem !== fsm.nextItem || otherTesting.index !== fsm.index || otherTesting.reachedEnd !== fsm.reachedEnd) { if (otherTesting.nextItem !== fsm.nextItem || otherTesting.index !== fsm.index || otherTesting.reachedEnd !== fsm.reachedEnd) {
throw new Error('udtirane') throw new Error('udtirane')
} }
*/
const result = f(fsm) const result = f(fsm)
if (fsm.reachedEnd) { if (fsm.reachedEnd) {
fsm.reachedEnd = false fsm.reachedEnd = false
@ -77,7 +78,7 @@ export const useSearchMarker = (tr, yarray, index, f) => {
} }
fsm.rel = 0 fsm.rel = 0
} }
if (!createFreshMarker && fsm.nextItem !== prevItem) { if (!createFreshMarker) {
// reused old marker and we moved to a different position // reused old marker and we moved to a different position
prevItem.marker = false prevItem.marker = false
} }

View File

@ -10,6 +10,7 @@ import {
transact, transact,
ListIterator, ListIterator,
useSearchMarker, useSearchMarker,
createRelativePositionFromTypeIndex,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@ -134,6 +135,32 @@ export class YArray extends AbstractType {
} }
} }
/**
* @param {number} start Inclusive move-start
* @param {number} end Inclusive move-end
* @param {number} target
* @param {number} assocStart >=0 if start should be associated with the right character. See relative-position assoc parameter.
* @param {number} assocEnd >= 0 if end should be associated with the right character.
*/
move (start, end, target, assocStart = 1, assocEnd = -1) {
if (start <= target && target <= end) {
// It doesn't make sense to move a range into the same range (it's basically a no-op).
return
}
if (this.doc !== null) {
transact(this.doc, transaction => {
useSearchMarker(transaction, this, target, walker => {
const left = createRelativePositionFromTypeIndex(this, start, assocStart)
const right = createRelativePositionFromTypeIndex(this, end + 1, assocEnd)
walker.insertMove(transaction, left, right)
})
})
} else {
const content = /** @type {Array<any>} */ (this._prelimContent).splice(start, end - start + 1)
;/** @type {Array<any>} */ (this._prelimContent).splice(target, 0, ...content)
}
}
/** /**
* Appends content to this YArray. * Appends content to this YArray.
* *

View File

@ -11,7 +11,7 @@ import {
ContentType, ContentType,
ContentDoc, ContentDoc,
Doc, Doc,
ID, AbstractContent, ContentMove, Transaction, Item, AbstractType // eslint-disable-line RelativePosition, ID, AbstractContent, ContentMove, Transaction, Item, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
const lengthExceeded = error.create('Length exceeded!') const lengthExceeded = error.create('Length exceeded!')
@ -122,8 +122,8 @@ export class ListIterator {
len += this.rel len += this.rel
this.rel = 0 this.rel = 0
} }
while (item && !this.reachedEnd && (len > 0 || (len === 0 && (!item.countable || item.deleted)))) { while (item && !this.reachedEnd && (len > 0 || (len === 0 && (!item.countable || item.deleted || item === this.currMoveEnd)))) {
if (item.countable && !item.deleted && item.moved === this.currMove) { if (item.countable && !item.deleted && item.moved === this.currMove && len > 0) {
len -= item.length len -= item.length
if (len < 0) { if (len < 0) {
this.rel = item.length + len this.rel = item.length + len
@ -228,10 +228,10 @@ export class ListIterator {
_slice (tr, len, value, slice, concat) { _slice (tr, len, value, slice, concat) {
this.index += len this.index += len
while (len > 0 && !this.reachedEnd) { while (len > 0 && !this.reachedEnd) {
while (this.nextItem && this.nextItem.countable && !this.reachedEnd && len > 0) { while (this.nextItem && this.nextItem.countable && !this.reachedEnd && len > 0 && this.nextItem !== this.currMoveEnd) {
if (!this.nextItem.deleted) { if (!this.nextItem.deleted && this.nextItem.moved === this.currMove) {
const item = this.nextItem const item = this.nextItem
const slicedContent = slice(this.nextItem.content, this.rel, len) const slicedContent = slice(item.content, this.rel, len)
len -= slicedContent.length len -= slicedContent.length
value = concat(value, slicedContent) value = concat(value, slicedContent)
if (item.length !== slicedContent.length) { if (item.length !== slicedContent.length) {
@ -268,18 +268,16 @@ export class ListIterator {
const sm = this.type._searchMarker const sm = this.type._searchMarker
let item = this.nextItem let item = this.nextItem
while (len > 0 && !this.reachedEnd) { while (len > 0 && !this.reachedEnd) {
while (item && item.countable && !this.reachedEnd && len > 0) { while (item && !item.deleted && item.countable && !this.reachedEnd && len > 0) {
if (!item.deleted) { if (this.rel > 0) {
if (this.rel > 0) { item = getItemCleanStart(tr, createID(item.id.client, item.id.clock + this.rel))
item = getItemCleanStart(tr, createID(item.id.client, item.id.clock + this.rel)) this.rel = 0
this.rel = 0
}
if (len < item.length) {
getItemCleanStart(tr, createID(item.id.client, item.id.clock + len))
}
len -= item.length
item.delete(tr)
} }
if (len < item.length) {
getItemCleanStart(tr, createID(item.id.client, item.id.clock + len))
}
len -= item.length
item.delete(tr)
if (item.right) { if (item.right) {
item = item.right item = item.right
} else { } else {
@ -300,12 +298,8 @@ export class ListIterator {
/** /**
* @param {Transaction} tr * @param {Transaction} tr
* @param {Array<Object<string,any>|Array<any>|boolean|number|null|string|Uint8Array>} content
*/ */
insertArrayValue (tr, content) { _splitRel (tr) {
/**
* @type {Item | null}
*/
if (this.rel > 0) { if (this.rel > 0) {
/** /**
* @type {ID} * @type {ID}
@ -314,6 +308,14 @@ export class ListIterator {
this.nextItem = getItemCleanStart(tr, createID(itemid.client, itemid.clock + this.rel)) this.nextItem = getItemCleanStart(tr, createID(itemid.client, itemid.clock + this.rel))
this.rel = 0 this.rel = 0
} }
}
/**
* @param {Transaction} tr
* @param {Array<AbstractContent>} content
*/
insertContents (tr, content) {
this._splitRel(tr)
const sm = this.type._searchMarker const sm = this.type._searchMarker
const parent = this.type const parent = this.type
const store = tr.doc.store const store = tr.doc.store
@ -327,18 +329,55 @@ export class ListIterator {
* @type {Item | null} * @type {Item | null}
*/ */
let left = this.left let left = this.left
content.forEach(c => {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, c)
left.integrate(tr, 0)
})
if (right === null && left !== null) {
this.nextItem = left
this.reachedEnd = true
} else {
this.nextItem = right
}
if (sm) {
updateMarkerChanges(sm, this.index, content.length, this)
}
}
/**
* @param {Transaction} tr
* @param {RelativePosition} start
* @param {RelativePosition} end
*/
insertMove (tr, start, end) {
this.insertContents(tr, [new ContentMove(start, end, 1)]) // @todo adjust priority
// @todo is there a better alrogirthm to update searchmarkers? We could simply remove the markers that are in the updated range.
// Also note that searchmarkers are updated in insertContents as well.
const sm = this.type._searchMarker
if (sm) sm.length = 0
}
/**
* @param {Transaction} tr
* @param {Array<Object<string,any>|Array<any>|boolean|number|null|string|Uint8Array>} values
*/
insertArrayValue (tr, values) {
this._splitRel(tr)
/**
* @type {Array<AbstractContent>}
*/
const contents = []
/** /**
* @type {Array<Object|Array<any>|number|null>} * @type {Array<Object|Array<any>|number|null>}
*/ */
let jsonContent = [] let jsonContent = []
const packJsonContent = () => { const packJsonContent = () => {
if (jsonContent.length > 0) { if (jsonContent.length > 0) {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent)) contents.push(new ContentAny(jsonContent))
left.integrate(tr, 0)
jsonContent = [] jsonContent = []
} }
} }
content.forEach(c => { values.forEach(c => {
if (c === null) { if (c === null) {
jsonContent.push(c) jsonContent.push(c)
} else { } else {
@ -355,17 +394,14 @@ export class ListIterator {
switch (c.constructor) { switch (c.constructor) {
case Uint8Array: case Uint8Array:
case ArrayBuffer: case ArrayBuffer:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c)))) contents.push(new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
left.integrate(tr, 0)
break break
case Doc: case Doc:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc(/** @type {Doc} */ (c))) contents.push(new ContentDoc(/** @type {Doc} */ (c)))
left.integrate(tr, 0)
break break
default: default:
if (c instanceof AbstractType) { if (c instanceof AbstractType) {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c)) contents.push(new ContentType(c))
left.integrate(tr, 0)
} else { } else {
throw new Error('Unexpected content type in insert operation') throw new Error('Unexpected content type in insert operation')
} }
@ -374,16 +410,8 @@ export class ListIterator {
} }
}) })
packJsonContent() packJsonContent()
if (right === null && left !== null) { this.insertContents(tr, contents)
this.nextItem = left this.index += values.length
this.reachedEnd = true
} else {
this.nextItem = right
}
if (sm) {
updateMarkerChanges(sm, this.index, content.length, this)
}
this.index += content.length
} }
/** /**

View File

@ -1,7 +1,8 @@
import { import {
isDeleted, isDeleted,
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line getMovedCoords,
ContentMove, Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as set from 'lib0/set' import * as set from 'lib0/set'
@ -153,62 +154,105 @@ export class YEvent {
get changes () { get changes () {
let changes = this._changes let changes = this._changes
if (changes === null) { if (changes === null) {
const target = this.target this.transaction.doc.transact(tr => {
const added = set.create() const target = this.target
const deleted = set.create() const added = set.create()
/** const deleted = set.create()
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/
const delta = []
changes = {
added,
deleted,
delta,
keys: this.keys
}
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (changed.has(null)) {
/** /**
* @type {any} * @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/ */
let lastOp = null const delta = []
const packOp = () => { changes = {
if (lastOp) { added,
delta.push(lastOp) deleted,
} delta,
keys: this.keys
} }
for (let item = target._start; item !== null; item = item.right) { const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (item.deleted) { if (changed.has(null)) {
if (this.deletes(item) && !this.adds(item)) { /**
if (lastOp === null || lastOp.delete === undefined) { * @type {Array<{ end: Item | null, move: Item | null, isNew : boolean }>}
packOp() */
lastOp = { delete: 0 } const movedStack = []
} /**
lastOp.delete += item.length * @type {Item | null}
deleted.add(item) */
} // else nop let currMove = null
} else { /**
if (this.adds(item)) { * @type {boolean}
if (lastOp === null || lastOp.insert === undefined) { */
packOp() let currMoveIsNew = false
lastOp = { insert: [] } /**
} * @type {Item | null}
lastOp.insert = lastOp.insert.concat(item.content.getContent()) */
added.add(item) let currMoveEnd = null
} else { /**
if (lastOp === null || lastOp.retain === undefined) { * @type {any}
packOp() */
lastOp = { retain: 0 } let lastOp = null
} const packOp = () => {
lastOp.retain += item.length if (lastOp) {
delta.push(lastOp)
} }
} }
for (let item = target._start; item !== null;) {
if (item === currMoveEnd) {
item = currMove
const { end, move, isNew } = movedStack.pop() || { end: null, move: null, isNew: false }
currMoveIsNew = isNew
currMoveEnd = end
currMove = move
} else if (item.content.constructor === ContentMove) {
if (item.moved === currMove) {
movedStack.push({ end: currMoveEnd, move: currMove, isNew: currMoveIsNew })
const { start, end } = getMovedCoords(item.content, tr)
currMove = item
currMoveEnd = end
currMoveIsNew = this.adds(item)
item = start
continue // do not move to item.right
}
} else if (item.moved !== currMove) {
if (!currMoveIsNew && item.countable && !this.adds(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
}
} else if (item.deleted) {
if (!currMoveIsNew && this.deletes(item) && !this.adds(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
deleted.add(item)
}
} else {
if (currMoveIsNew || this.adds(item)) {
if (lastOp === null || lastOp.insert === undefined) {
packOp()
lastOp = { insert: [] }
}
lastOp.insert = lastOp.insert.concat(item.content.getContent())
added.add(item)
} else {
if (lastOp === null || lastOp.retain === undefined) {
packOp()
lastOp = { retain: 0 }
}
lastOp.retain += item.length
}
}
item = /** @type {Item} */ (item).right
}
if (lastOp !== null && lastOp.retain === undefined) {
packOp()
}
} }
if (lastOp !== null && lastOp.retain === undefined) { this._changes = changes
packOp() })
}
}
this._changes = changes
} }
return /** @type {any} */ (changes) return /** @type {any} */ (changes)
} }

View File

@ -373,7 +373,10 @@ export const compare = users => {
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1])) t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store)) compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
compareStructStores(users[i].store, users[i + 1].store) compareStructStores(users[i].store, users[i + 1].store)
// @todo
// test list-iterator // test list-iterator
// console.log('dutiraneduiaentdr', users[0].getArray('array')._searchMarker)
/*
{ {
const user = users[0] const user = users[0]
user.transact(tr => { user.transact(tr => {
@ -396,6 +399,7 @@ export const compare = users => {
}) })
}) })
} }
*/
} }
users.map(u => u.destroy()) users.map(u => u.destroy())
} }

View File

@ -1,4 +1,4 @@
import { init, compare, applyRandomTests, Doc, AbstractType } from './testHelper.js' // eslint-disable-line import { init, compare, applyRandomTests, Doc, AbstractType, TestConnector } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js' import * as Y from '../src/index.js'
import * as t from 'lib0/testing' import * as t from 'lib0/testing'
@ -432,6 +432,43 @@ export const testEventTargetIsSetCorrectlyOnRemote = tc => {
compare(users) compare(users)
} }
/**
* @param {t.TestCase} tc
*/
export const testMove = tc => {
{
// move in uninitialized type
const yarr = new Y.Array()
yarr.insert(0, [1, 2, 3])
yarr.move(1, 1, 0)
// @ts-ignore
t.compare(yarr._prelimContent, [2, 1, 3])
}
const { array0, array1, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event0 = null
/**
* @type {any}
*/
let event1 = null
array0.observe(event => {
event0 = event
})
array1.observe(event => {
event1 = event
})
array0.insert(0, [1, 2, 3])
array0.move(1, 1, 0)
t.compare(array0.toArray(), [2, 1, 3])
t.compare(event0.delta, [{ insert: [2] }, { retain: 1 }, { delete: 1 }])
Y.applyUpdate(users[1], Y.encodeStateAsUpdate(users[0]))
t.compare(array1.toArray(), [2, 1, 3])
t.compare(event1.delta, [{ insert: [2, 1, 3] }])
compare(users)
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@ -473,7 +510,6 @@ const arrayTransactions = [
yarray.insert(pos, content) yarray.insert(pos, content)
oldContent.splice(pos, 0, ...content) oldContent.splice(pos, 0, ...content)
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
t.compare(yarray.toJSON(), yarray.toArray().map(x => x instanceof AbstractType ? x.toJSON() : x))
}, },
function insertTypeArray (user, gen) { function insertTypeArray (user, gen) {
const yarray = user.getArray('array') const yarray = user.getArray('array')