7

In my spreadsheet, I have a running script, which is using the getLastRow() function as an essential part of its logic.

Ever since I applied the array formula in one of my columns, the getLastRow() function doesn't work properly. It seems that the array formula is "applying" all the way to the bottom of the sheet even when there are no other values in the other columns and thus, getLastRow() is returning the last row where there is an array formula, instead of the actual non-empty row.

Writing a slow function which checks which cells are empty is not an option for me, since the script will run out of time with such thing running (it has tens of thousands of rows).

Does anyone have any suggestions for a workaround?

Here is the ARRAYFORMULA:

=ArrayFormula(IF(A2:A="",,WEEKNUM(A2:A, 2)))
TheMaster
  • 45,448
  • 6
  • 62
  • 85
Robert Ross
  • 1,151
  • 2
  • 19
  • 47

4 Answers4

10

Issue:

  • Undesirable addition of empty strings in all the available rows by traditional usage of ARRAYFORMULA(IF(A:A="",...))

Solution:

  • Using ARRAYFORMULA properly with INDEX/COUNTA(to determine the last row that's needed) ensures formula is only filled upto the needed row instead of a camouflage

  • INDEX/COUNTA: INDEX returns a value as well as a cell reference. A2:INDEX(A2:A,COUNTA(A2:A)) => If COUNTA(...) returns 10 => A2:INDEX(A2:A,10) => A2:A11 is the final reference feeded to weeknum

  • Assuming there are no blanks in between your data,

    =ARRAYFORMULA(WEEKNUM(A2:INDEX(A2:A,COUNTA(A2:A)),2))
    
  • Another alternative is to use ARRAY_CONSTRAIN/COUNTA:

    =ARRAY_CONSTRAIN(ARRAYFORMULA(WEEKNUM(A2:A, 2)),COUNTA(A2:A))
    
  • The usage of COUNTA assumes there are no blank cells in between. If there are any, you may need to manually add a offset. If there are two blank cells, add 2 to COUNTA

    A2:INDEX(A2:A,COUNTA(A2:A)+2)
    

Unless Google does inbuilt optimizations, INDEX/COUNTA is preferred over ARRAY_CONSTRAIN.


Update:

With the adevent of XMATCH and named functions, this should become easier. Create a named function like:

LR(rng)(LAST ROW):

=INDEX(rng,XMATCH("*",rng,2,-1))

LRA(rng, blanks)(LASTROW ADVANCED):

=INDEX(rng,COUNTA(rng)+blanks)

XMATCH does the search in reverse and in theory, should be faster. But the wildcard * search won't consider numbers/dates. So, if the last row is a number or a date, we need to use COUNTA. You can then use it like this:

=ARRAYFORMULA(WEEKNUM(A2:LRA(A:A,),2))

If there are like 5 intervening blanks, use

=ARRAYFORMULA(WEEKNUM(A2:LRA(A:A,5),2))

It might be hard to fix those array formulas with INDEX/COUNTA manually, so I made a script. This is just a proof of concept and alpha quality. So, test it in a copy of your spreadsheet rather than on the original. Having said that, I'm sure it'll handle most common cases without trouble.

/**
 * @see https://stackoverflow.com/a/46884012
 */
function fixArrayFormulas_so46884012() {
  const ss = SpreadsheetApp.getActive()/*.getSheetByName('Sheet1')*/,
    map = new Map([
      [
        // Normalize first part of range
        /* A:F */ String.raw`([a-z]+):([a-z]+)`,
        /* A1:F*/ String.raw`$11:$2`,
      ],
      [
        // Convert any previous index/counta to normal ranges
        /* A1:INDEX(F:F,COUNTA(F:F)) */ String.raw`([a-z]+\d+):INDEX\(([a-z]+)\d*:\w+,COUNTA\(\w+:\w+\)\)`,
        /*A1:F*/ String.raw`$1:$2`,
      ],
      [
        // Convert open ended ranges to  index/counta ranges
        /*A1:F*/ String.raw`([a-z]+\d+:)([a-z]+)`,
        /* A1:INDEX(F:F,COUNTA(F:F)) */ `$1INDEX($2:$2,COUNTA($2:$2))`,
      ],
    ]);
  map.forEach((v, k) =>
    ss
      .createTextFinder(k)
      .matchFormulaText(true)
      .useRegularExpression(true)
      .replaceAllWith(v)
  );
}
TheMaster
  • 45,448
  • 6
  • 62
  • 85
  • @TheMaster can you please explain this usage of INDEX() in the range expression? – PopGoesTheWza Aug 22 '19 at 17:55
  • @PopGoesTheWza What do you want to know? `INDEX` returns a value as well as a cell reference. `A2:INDEX(A2:A,COUNTA(A2:A))`=> If count is 10=> `a2:index(a2:a,10)`=>a2:a11 is the final reference feeded to weeknum – TheMaster Aug 22 '19 at 18:26
  • @TheMaster I never suspected a range could be thought as two reference addresses and a ":" operator. I was never able to find a document which detail the syntax. Thanks for showing it to us. – PopGoesTheWza Aug 22 '19 at 19:56
  • 1
    This is excellent! However the completely re-written formula makes it harder to understand what's being used. This version is a small change to the original formula (using the same technique as this answer), replacing the `A2:A` with `A2:INDEX(A2:A,COUNTA(A2:A))`. Which means "From A2 to the last A cell with data": `=ArrayFormula(IF(A2::INDEX(A2:A,COUNTA(A2:A))="",,WEEKNUM(A2:A, 2)))` *To be clear: This isn't as efficient as the answer given (it has three A:2:A lookups), but is just showing the specific technique to determine the last cell.* – RedYeti Dec 17 '19 at 13:16
  • Hi @TheMaster, Nice answer +1, could also suggest `max(filer(row(a2:a);a2:a<>"")`. It will make a formula even uglier but will catch the last non-empty row. If the original formula has lot's of ranges, we'll have to repeat `A2:INDEX` construction, right? – Max Makhrov Nov 15 '21 at 07:19
  • 1
    I've just got a proper workflow for this. You may use a separate cell to calculate the last filled row in your data. And use formula like this: `=INDEX(A2:index(A2:A,C1)*B2:index(B2:B,C1))` It looks prettier, and it has the only one calculated cell `C1` with the last row number in it. – Max Makhrov Nov 15 '21 at 07:28
  • 1
    @MaxMakhrov `If the original formula has lot's of ranges, we'll have to repeat A2:INDEX construction, right?` Yes. But I don't think there's actual recalculation going on. I read in some Google support forums that if a formula has a repeated expression, the expression is only calculated the first time and the calculated value is used every time, the same expression is found in the formula. `filter` is great, but theoretical performance wise, I don't think it's better than `counta`: Creating a num array, filtering it to get a filtered num array and finding the max. Calculated cell is prettier. – TheMaster Nov 15 '21 at 08:23
1

Another solution is to temporarily remove the ArrayFormulas with

sheet.getRange("location of array formula").setValue('');

Then calculate lastRow

var lastRow = sheet.getLastRow();

Then replace the arrayformula

sheet.getRange("location of array formula").setFormula('the formula');
slfan
  • 8,950
  • 115
  • 65
  • 78
  • Hmmm... well if you wanted to go this way you could at least get the current formula with .getFormula() (without writing a few lines of code that's: var originalFormula = sheet.getRange("location of array formula").getFormula()). Then set it back: ...setFormula(originalFormula); – RedYeti Dec 17 '19 at 12:57
1

Here is a function you can use to determine the "true" lastRow and lastColumn values of a Sheet values. It will handle both messy ArrayFormula() and merged cells.

function getSheetBoundaries2(sheet) {
  var dim = { lastColumn: 1, lastRow: 1 };
  sheet.getDataRange().getMergedRanges()
    .forEach(function (e) {
      var lastColumn = e.getLastColumn();
      var lastRow = e.getLastRow();
      if (lastColumn > dim.lastColumn) dim.lastColumn = lastColumn;
      if (lastRow > dim.lastRow) dim.lastRow = lastRow;
    });
  var rowCount = sheet.getMaxRows();
  var columnCount = sheet.getMaxColumns();
  var dataRange = sheet.getRange(1, 1, rowCount, columnCount).getValues();
  for (var rowIndex = rowCount; rowIndex > 0; rowIndex -= 1) {
    var row = dataRange[rowIndex - 1];
    if (row.join('').length > 0) {
      for (var columnIndex = columnCount; columnIndex > dim.lastColumn; columnIndex -= 1) {
        if (("" + row[columnIndex - 1]).length > 0) dim.lastColumn = columnIndex;
      }
      if (dim.lastRow < rowIndex) dim.lastRow = rowIndex;
    }
  }
  return dim;
}
PopGoesTheWza
  • 558
  • 4
  • 12
0

I recently tried and if you use null instead of the ' 's in the arrayFormula with the IF function, getLastRow works. I also did some tests with dataRange(),getValues().length or .getValues().filter(String).length which I often used. Xmatch seems complicated in this context