From 4063328f97c0180ceab565cc3f411e3dcc07bca8 Mon Sep 17 00:00:00 2001 From: "Brian S. O'Neill" Date: Fri, 26 Oct 2007 21:24:43 +0000 Subject: Added support for outer joins. --- src/main/java/com/amazon/carbonado/Storage.java | 6 +- .../carbonado/cursor/FilteredCursorGenerator.java | 11 +- .../java/com/amazon/carbonado/filter/Filter.java | 6 +- .../com/amazon/carbonado/filter/FilterParser.java | 127 +++++++++-- .../amazon/carbonado/filter/PropertyFilter.java | 34 ++- .../com/amazon/carbonado/info/ChainedProperty.java | 234 ++++++++++++++++++--- .../amazon/carbonado/qe/IndexedQueryAnalyzer.java | 8 + .../amazon/carbonado/repo/jdbc/JDBCStorage.java | 74 +++++-- 8 files changed, 429 insertions(+), 71 deletions(-) (limited to 'src/main/java/com/amazon') diff --git a/src/main/java/com/amazon/carbonado/Storage.java b/src/main/java/com/amazon/carbonado/Storage.java index 551e1eb..a1cc420 100644 --- a/src/main/java/com/amazon/carbonado/Storage.java +++ b/src/main/java/com/amazon/carbonado/Storage.java @@ -90,8 +90,12 @@ public interface Storage { * | "(" Filter ")" * PropertyFilter = ChainedProperty RelOp "?" * RelOp = "=" | "!=" | "<" | ">=" | ">" | "<=" - * ChainedProperty = Identifier { "." Identifier } * ChainedFilter = ChainedProperty "(" [ Filter ] ")" + * ChainedProperty = Identifier + * | InnerJoin "." ChainedProperty + * | OuterJoin "." ChainedProperty + * InnerJoin = Identifier + * OuterJoin = '(' Identifier ')' * * * @param filter query filter expression diff --git a/src/main/java/com/amazon/carbonado/cursor/FilteredCursorGenerator.java b/src/main/java/com/amazon/carbonado/cursor/FilteredCursorGenerator.java index 04ea2e8..dba2062 100644 --- a/src/main/java/com/amazon/carbonado/cursor/FilteredCursorGenerator.java +++ b/src/main/java/com/amazon/carbonado/cursor/FilteredCursorGenerator.java @@ -464,18 +464,23 @@ class FilteredCursorGenerator { /** * Generated code checks if chained properties resolve to null, and if - * so, branches to the current scope's fail location. + * so, branches to the current scope's fail or success location. */ private void loadChainedProperty(CodeBuilder b, ChainedProperty chained) { b.loadLocal(mStorableVar); loadProperty(b, chained.getPrimeProperty()); for (int i=0; i * * @author Brian S O'Neill diff --git a/src/main/java/com/amazon/carbonado/filter/FilterParser.java b/src/main/java/com/amazon/carbonado/filter/FilterParser.java index 5d3762e..ed9cec6 100644 --- a/src/main/java/com/amazon/carbonado/filter/FilterParser.java +++ b/src/main/java/com/amazon/carbonado/filter/FilterParser.java @@ -115,7 +115,22 @@ class FilterParser { private Filter parseEntityFilter() { int c = nextCharIgnoreWhitespace(); - if (c == '(') { + + parenFilter: if (c == '(') { + // Need to peek ahead to see if this is an outer join. + int savedPos = mPos; + try { + if (Character.isJavaIdentifierStart(nextCharIgnoreWhitespace())) { + mPos--; + parseIdentifier(); + if (nextCharIgnoreWhitespace() == ')') { + // Is an outer join, so back up. + break parenFilter; + } + } + } finally { + mPos = savedPos; + } Filter test = parseFilter(); c = nextCharIgnoreWhitespace(); if (c != ')') { @@ -123,31 +138,31 @@ class FilterParser { throw error("Right paren expected"); } return test; - } else { + } + + mPos--; + ChainedProperty chained = parseChainedProperty(); + c = nextCharIgnoreWhitespace(); + if (c != '(') { mPos--; - ChainedProperty chained = parseChainedProperty(); - c = nextCharIgnoreWhitespace(); - if (c != '(') { - mPos--; - return parsePropertyFilter(chained); - } + return parsePropertyFilter(chained); + } - Filter subFilter; + Filter subFilter; + c = nextCharIgnoreWhitespace(); + if (c == ')') { + subFilter = null; + } else { + mPos--; + subFilter = parseChainedFilter(chained); c = nextCharIgnoreWhitespace(); - if (c == ')') { - subFilter = null; - } else { + if (c != ')') { mPos--; - subFilter = parseChainedFilter(chained); - c = nextCharIgnoreWhitespace(); - if (c != ')') { - mPos--; - throw error("Right paren expected"); - } + throw error("Right paren expected"); } - - return ExistsFilter.build(chained, subFilter, false); } + + return ExistsFilter.build(chained, subFilter, false); } private PropertyFilter parsePropertyFilter(ChainedProperty chained) { @@ -239,6 +254,18 @@ class FilterParser { @SuppressWarnings("unchecked") ChainedProperty parseChainedProperty() { + List outerJoinList = null; + int lastOuterJoinPos = -1; + + if (nextChar() == '(') { + lastOuterJoinPos = mPos - 1; + // Skip any whitespace after paren. + nextCharIgnoreWhitespace(); + outerJoinList = new ArrayList(4); + outerJoinList.add(true); + } + mPos--; + String ident = parseIdentifier(); StorableProperty prime = StorableIntrospector.examine(mType).getAllProperties().get(ident); @@ -248,8 +275,21 @@ class FilterParser { mType.getName() + '"'); } + if (outerJoinList != null && outerJoinList.get(0)) { + if (nextCharIgnoreWhitespace() != ')') { + mPos--; + throw error("Right paren expected"); + } + } + if (nextCharIgnoreWhitespace() != '.') { mPos--; + if (outerJoinList != null && outerJoinList.get(0)) { + if (lastOuterJoinPos >= 0) { + mPos = lastOuterJoinPos; + } + throw error("Outer join not allowed for non-chained property"); + } return ChainedProperty.get(prime); } @@ -257,7 +297,28 @@ class FilterParser { Class type = prime.getType(); while (true) { + lastOuterJoinPos = -1; + + if (nextChar() == '(') { + lastOuterJoinPos = mPos - 1; + // Skip any whitespace after paren. + nextCharIgnoreWhitespace(); + if (outerJoinList == null) { + outerJoinList = new ArrayList(4); + // Fill in false values. + outerJoinList.add(false); // prime is inner join + for (int i=chain.size(); --i>=0; ) { + outerJoinList.add(false); + } + } + outerJoinList.add(true); + } else if (outerJoinList != null) { + outerJoinList.add(false); + } + mPos--; + ident = parseIdentifier(); + if (Storable.class.isAssignableFrom(type)) { StorableInfo info = StorableIntrospector.examine((Class) type); @@ -274,14 +335,38 @@ class FilterParser { throw error("Property \"" + ident + "\" not found for type \"" + type.getName() + "\" because it has no properties"); } + + if (outerJoinList != null && outerJoinList.get(outerJoinList.size() - 1)) { + if (nextCharIgnoreWhitespace() != ')') { + mPos--; + throw error("Right paren expected"); + } + } + if (nextCharIgnoreWhitespace() != '.') { mPos--; break; } } + boolean[] outerJoin = null; + if (outerJoinList != null) { + if (outerJoinList.get(outerJoinList.size() - 1)) { + if (lastOuterJoinPos >= 0) { + mPos = lastOuterJoinPos; + } + throw error("Outer join not allowed for last property in chain"); + } + outerJoin = new boolean[outerJoinList.size()]; + for (int i=outerJoinList.size(); --i>=0; ) { + outerJoin[i] = outerJoinList.get(i); + } + } + return ChainedProperty - .get(prime, (StorableProperty[]) chain.toArray(new StorableProperty[chain.size()])); + .get(prime, + (StorableProperty[]) chain.toArray(new StorableProperty[chain.size()]), + outerJoin); } private String parseIdentifier() { diff --git a/src/main/java/com/amazon/carbonado/filter/PropertyFilter.java b/src/main/java/com/amazon/carbonado/filter/PropertyFilter.java index c4a585d..c3c5c79 100644 --- a/src/main/java/com/amazon/carbonado/filter/PropertyFilter.java +++ b/src/main/java/com/amazon/carbonado/filter/PropertyFilter.java @@ -26,6 +26,7 @@ import org.cojen.classfile.TypeDesc; import com.amazon.carbonado.Storable; import com.amazon.carbonado.info.ChainedProperty; +import com.amazon.carbonado.info.StorableProperty; /** * Filter tree node that performs a relational test against a specific property @@ -100,6 +101,10 @@ public class PropertyFilter extends Filter { if (property == null || op == null) { throw new IllegalArgumentException(); } + if (property.isOuterJoin(property.getChainCount())) { + throw new IllegalArgumentException + ("Last property in chain cannot be an outer join: " + property); + } mProperty = property; mOp = op; mBindID = bindID; @@ -108,10 +113,35 @@ public class PropertyFilter extends Filter { @Override public PropertyFilter not() { + ChainedProperty property = mProperty; + + if (property.getChainCount() > 0) { + // Flip inner/outer joins. + + int chainCount = property.getChainCount(); + StorableProperty[] chain = new StorableProperty[chainCount]; + for (int i=0; i implements Appender { */ @SuppressWarnings("unchecked") public static ChainedProperty get(StorableProperty prime) { - return (ChainedProperty) cCanonical.put(new ChainedProperty(prime, null)); + return (ChainedProperty) cCanonical.put(new ChainedProperty(prime, null, null)); } /** @@ -58,7 +58,22 @@ public class ChainedProperty implements Appender { @SuppressWarnings("unchecked") public static ChainedProperty get(StorableProperty prime, StorableProperty... chain) { - return (ChainedProperty) cCanonical.put(new ChainedProperty(prime, chain)); + return (ChainedProperty) cCanonical.put(new ChainedProperty(prime, chain, null)); + } + + /** + * Returns a canonical instance. + * + * @throws IllegalArgumentException if prime is null or if chained + * properties are not formed properly + * @since 1.2 + */ + @SuppressWarnings("unchecked") + public static ChainedProperty get(StorableProperty prime, + StorableProperty[] chain, + boolean[] outerJoin) { + return (ChainedProperty) cCanonical.put + (new ChainedProperty(prime, chain, outerJoin)); } /** @@ -82,12 +97,20 @@ public class ChainedProperty implements Appender { String name; if (dot < 0) { - name = str; + name = str.trim(); } else { - name = str.substring(pos, dot); + name = str.substring(pos, dot).trim(); pos = dot + 1; } + List outerJoinList = null; + + if (name.startsWith("(") && name.endsWith(")")) { + outerJoinList = new ArrayList(4); + outerJoinList.add(true); + name = name.substring(1, name.length() - 1).trim(); + } + StorableProperty prime = info.getAllProperties().get(name); if (prime == null) { @@ -97,7 +120,11 @@ public class ChainedProperty implements Appender { } if (pos <= 0) { - return get(prime); + if (outerJoinList == null || !outerJoinList.get(0)) { + return get(prime); + } else { + return get(prime, null, new boolean[] {true}); + } } List> chain = new ArrayList>(4); @@ -106,12 +133,28 @@ public class ChainedProperty implements Appender { while (pos > 0) { dot = str.indexOf('.', pos); if (dot < 0) { - name = str.substring(pos); + name = str.substring(pos).trim(); pos = -1; } else { - name = str.substring(pos, dot); + name = str.substring(pos, dot).trim(); pos = dot + 1; } + + if (name.startsWith("(") && name.endsWith(")")) { + if (outerJoinList == null) { + outerJoinList = new ArrayList(4); + // Fill in false values. + outerJoinList.add(false); // prime is inner join + for (int i=chain.size(); --i>=0; ) { + outerJoinList.add(false); + } + } + outerJoinList.add(true); + name = name.substring(1, name.length() - 1).trim(); + } else if (outerJoinList != null) { + outerJoinList.add(false); + } + if (Storable.class.isAssignableFrom(type)) { StorableInfo propInfo = StorableIntrospector.examine((Class) type); @@ -131,24 +174,51 @@ public class ChainedProperty implements Appender { } } + boolean[] outerJoin = null; + if (outerJoinList != null) { + outerJoin = new boolean[outerJoinList.size()]; + for (int i=outerJoinList.size(); --i>=0; ) { + outerJoin[i] = outerJoinList.get(i); + } + } + return get(prime, - (StorableProperty[]) chain.toArray(new StorableProperty[chain.size()])); + (StorableProperty[]) chain.toArray(new StorableProperty[chain.size()]), + outerJoin); } private final StorableProperty mPrime; private final StorableProperty[] mChain; + private final boolean[] mOuterJoin; /** * @param prime must not be null * @param chain can be null if none - * @throws IllegalArgumentException if prime is null + * @param outerJoin can be null for all inner joins + * @throws IllegalArgumentException if prime is null or if outer join chain is too long */ - private ChainedProperty(StorableProperty prime, StorableProperty[] chain) { + private ChainedProperty(StorableProperty prime, StorableProperty[] chain, + boolean[] outerJoin) + { if (prime == null) { - throw new IllegalArgumentException(); + throw new IllegalArgumentException("No prime property"); } + mPrime = prime; mChain = (chain == null || chain.length == 0) ? null : chain.clone(); + + if (outerJoin != null) { + int expectedLength = (chain == null ? 0 : chain.length) + 1; + if (outerJoin.length > expectedLength) { + throw new IllegalArgumentException + ("Outer join array too long: " + outerJoin.length + " > " + expectedLength); + } + boolean[] newOuterJoin = new boolean[expectedLength]; + System.arraycopy(outerJoin, 0, newOuterJoin, 0, outerJoin.length); + outerJoin = newOuterJoin; + } + + mOuterJoin = outerJoin; } public StorableProperty getPrimeProperty() { @@ -219,6 +289,9 @@ public class ChainedProperty implements Appender { return mChain == null ? 0 : mChain.length; } + /** + * @param index valid range is 0 to chainCount - 1 + */ public StorableProperty getChainedProperty(int index) throws IndexOutOfBoundsException { if (mChain == null) { throw new IndexOutOfBoundsException(); @@ -227,25 +300,74 @@ public class ChainedProperty implements Appender { } } + /** + * Returns true if the property at the given index should be treated as an + * outer join. Index zero is the prime property. + * + * @param index valid range is 0 to chainCount + * @since 1.2 + */ + public boolean isOuterJoin(int index) throws IndexOutOfBoundsException { + if (index < 0) { + throw new IndexOutOfBoundsException(); + } + if (mOuterJoin == null) { + if (index > getChainCount()) { + throw new IndexOutOfBoundsException(); + } + return false; + } + return mOuterJoin[index]; + } + /** * Returns a new ChainedProperty with another property appended. */ public ChainedProperty append(StorableProperty property) { + return append(property, false); + } + + /** + * Returns a new ChainedProperty with another property appended. + * + * @param outerJoin pass true for outer join + * @since 1.2 + */ + public ChainedProperty append(StorableProperty property, boolean outerJoin) { + if (property == null) { + throw new IllegalArgumentException(); + } + StorableProperty[] newChain = new StorableProperty[getChainCount() + 1]; if (newChain.length > 1) { System.arraycopy(mChain, 0, newChain, 0, mChain.length); } newChain[newChain.length - 1] = property; - return get(mPrime, newChain); + + boolean[] newOuterJoin = mOuterJoin; + + if (outerJoin) { + newOuterJoin = new boolean[newChain.length + 1]; + if (mOuterJoin != null) { + System.arraycopy(mOuterJoin, 0, newOuterJoin, 0, mOuterJoin.length); + } + newOuterJoin[newOuterJoin.length - 1] = true; + } + + return get(mPrime, newChain, newOuterJoin); } /** * Returns a new ChainedProperty with another property appended. */ public ChainedProperty append(ChainedProperty property) { + if (property == null) { + throw new IllegalArgumentException(); + } + final int propChainCount = property.getChainCount(); if (propChainCount == 0) { - return append(property.getPrimeProperty()); + return append(property.getPrimeProperty(), property.isOuterJoin(0)); } StorableProperty[] newChain = @@ -262,7 +384,19 @@ public class ChainedProperty implements Appender { newChain[pos++] = property.getChainedProperty(i); } - return get(mPrime, newChain); + boolean[] newOuterJoin = mOuterJoin; + + if (property.mOuterJoin != null) { + newOuterJoin = new boolean[newChain.length + 1]; + if (mOuterJoin != null) { + System.arraycopy(mOuterJoin, 0, newOuterJoin, 0, mOuterJoin.length); + } + System.arraycopy(property.mOuterJoin, 0, + newOuterJoin, mOuterJoin.length, + property.mOuterJoin.length); + } + + return get(mPrime, newChain, newOuterJoin); } /** @@ -275,11 +409,23 @@ public class ChainedProperty implements Appender { throw new IllegalStateException(); } if (getChainCount() == 1) { - return get(mPrime); + if (!isOuterJoin(0)) { + return get(mPrime); + } else { + return get(mPrime, null, new boolean[] {true}); + } } StorableProperty[] newChain = new StorableProperty[getChainCount() - 1]; System.arraycopy(mChain, 0, newChain, 0, newChain.length); - return get(mPrime, newChain); + + boolean[] newOuterJoin = mOuterJoin; + + if (newOuterJoin != null && newOuterJoin.length > (newChain.length + 1)) { + newOuterJoin = new boolean[newChain.length + 1]; + System.arraycopy(mOuterJoin, 0, newOuterJoin, 0, newChain.length + 1); + } + + return get(mPrime, newChain, newOuterJoin); } /** @@ -293,11 +439,23 @@ public class ChainedProperty implements Appender { throw new IllegalStateException(); } if (getChainCount() == 1) { - return get(mChain[0]); + if (!isOuterJoin(1)) { + return get(mChain[0]); + } else { + return get(mChain[0], null, new boolean[] {true}); + } } StorableProperty[] newChain = new StorableProperty[getChainCount() - 1]; System.arraycopy(mChain, 1, newChain, 0, newChain.length); - return get(mChain[0], newChain); + + boolean[] newOuterJoin = mOuterJoin; + + if (newOuterJoin != null) { + newOuterJoin = new boolean[newChain.length + 1]; + System.arraycopy(mOuterJoin, 1, newOuterJoin, 0, mOuterJoin.length - 1); + } + + return get(mChain[0], newChain, newOuterJoin); } @Override @@ -309,6 +467,14 @@ public class ChainedProperty implements Appender { hash = hash * 31 + chain[i].hashCode(); } } + boolean[] outerJoin = mOuterJoin; + if (outerJoin != null) { + for (int i=outerJoin.length; --i>=0; ) { + if (outerJoin[i]) { + hash += 1; + } + } + } return hash; } @@ -324,8 +490,18 @@ public class ChainedProperty implements Appender { // equals method. Otherwise, canonical ChainedProperty instances // may refer to StorableProperty instances which are no longer // available through the Introspector. - return getType() == other.getType() && mPrime == other.mPrime - && identityEquals(mChain, other.mChain); + if (getType() == other.getType() && mPrime == other.mPrime + && identityEquals(mChain, other.mChain)) + { + // Compare outer joins. + int count = getChainCount() + 1; + for (int i=0; i implements Appender { */ @Override public String toString() { - if (mChain == null) { + if (mChain == null && !isOuterJoin(0)) { return mPrime.getName(); } StringBuilder buf = new StringBuilder(); @@ -380,7 +556,7 @@ public class ChainedProperty implements Appender { * many-to-one joins. */ public void appendTo(Appendable app) throws IOException { - app.append(mPrime.getName()); + appendPropTo(app, mPrime.getName(), isOuterJoin(0)); StorableProperty[] chain = mChain; if (chain != null) { app.append('.'); @@ -388,8 +564,18 @@ public class ChainedProperty implements Appender { if (i > 0) { app.append('.'); } - app.append(chain[i].getName()); + appendPropTo(app, chain[i].getName(), isOuterJoin(i + 1)); } } } + + private void appendPropTo(Appendable app, String name, boolean outer) throws IOException { + if (outer) { + app.append('('); + } + app.append(name); + if (outer) { + app.append(')'); + } + } } diff --git a/src/main/java/com/amazon/carbonado/qe/IndexedQueryAnalyzer.java b/src/main/java/com/amazon/carbonado/qe/IndexedQueryAnalyzer.java index 60cf3da..93d4666 100644 --- a/src/main/java/com/amazon/carbonado/qe/IndexedQueryAnalyzer.java +++ b/src/main/java/com/amazon/carbonado/qe/IndexedQueryAnalyzer.java @@ -209,12 +209,20 @@ public class IndexedQueryAnalyzer { if (!isProperJoin(chainedProp.getPrimeProperty())) { break evaluate; } + if (chainedProp.isOuterJoin(0)) { + // Outer joins cannot be optimized via foreign indexes. + break evaluate; + } int count = chainedProp.getChainCount(); for (int i=0; i extends StandardQueryFactory private static class JoinNode { // Joined property which led to this node. For root node, it is null. private final JDBCStorableProperty mProperty; + private final boolean mOuterJoin; private final JDBCStorableInfo mInfo; private final String mAlias; - private final Map mSubNodes; + private static class SubNodeKey { + final String mPropertyName; + final boolean mOuterJoin; + + SubNodeKey(String propertyName, boolean outerJoin) { + mPropertyName = propertyName; + mOuterJoin = outerJoin; + } + + @Override + public int hashCode() { + return mPropertyName.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof SubNodeKey) { + SubNodeKey other = (SubNodeKey) obj; + return mPropertyName.equals(other.mPropertyName) + && mOuterJoin == other.mOuterJoin; + } + return false; + } + + @Override + public String toString() { + return "propertyName=" + mPropertyName + ", outerJoin=" + mOuterJoin; + } + } + + private final Map mSubNodes; private boolean mAliasRequired; @@ -858,15 +892,19 @@ class JDBCStorage extends StandardQueryFactory * @param alias table alias in SQL statement, i.e. "T1" */ JoinNode(JDBCStorableInfo info, String alias) { - this(null, info, alias); + this(null, false, info, alias); } - private JoinNode(JDBCStorableProperty property, JDBCStorableInfo info, String alias) + private JoinNode(JDBCStorableProperty property, + boolean outerJoin, + JDBCStorableInfo info, + String alias) { mProperty = property; + mOuterJoin = outerJoin; mInfo = info; mAlias = alias; - mSubNodes = new LinkedHashMap(); + mSubNodes = new LinkedHashMap(); } /** @@ -891,8 +929,9 @@ class JDBCStorage extends StandardQueryFactory } else { property = chained.getChainedProperty(offset - 1); } - String name = property.getName(); - JoinNode subNode = mSubNodes.get(name); + boolean outerJoin = chained.isOuterJoin(offset); + SubNodeKey key = new SubNodeKey(property.getName(), outerJoin); + JoinNode subNode = mSubNodes.get(key); if (subNode != null) { return subNode.findAliasFor(chained, offset + 1); } @@ -931,15 +970,11 @@ class JDBCStorage extends StandardQueryFactory private void appendTailJoinTo(StatementBuilder fromClause) { for (JoinNode jn : mSubNodes.values()) { - // TODO: By default, joins are all inner. A join could become - // LEFT OUTER JOIN if the query filter has a term like this: - // "address = ? | address.state = ?", and the runtime value of - // address is null. Because of DNF transformation and lack of - // short-circuit ops, this syntax might be difficult to parse. - // This might be a better way of expressing an outer join: - // "address(.)state = ?". - - fromClause.append(" INNER JOIN"); + if (jn.mOuterJoin) { + fromClause.append(" LEFT OUTER JOIN"); + } else { + fromClause.append(" INNER JOIN"); + } jn.appendTableNameAndAliasTo(fromClause); fromClause.append(" ON "); int count = jn.mProperty.getJoinElementCount(); @@ -984,13 +1019,14 @@ class JDBCStorage extends StandardQueryFactory } else { property = chained.getChainedProperty(offset - 1); } - String name = property.getName(); - JoinNode subNode = mSubNodes.get(name); + boolean outerJoin = chained.isOuterJoin(offset); + SubNodeKey key = new SubNodeKey(property.getName(), outerJoin); + JoinNode subNode = mSubNodes.get(key); if (subNode == null) { JDBCStorableInfo info = repository.examineStorable(property.getJoinedType()); JDBCStorableProperty jProperty = repository.getJDBCStorableProperty(property); - subNode = new JoinNode(jProperty, info, aliasGenerator.nextAlias()); - mSubNodes.put(name, subNode); + subNode = new JoinNode(jProperty, outerJoin, info, aliasGenerator.nextAlias()); + mSubNodes.put(key, subNode); } subNode.addJoin(repository, chained, aliasGenerator, offset + 1); } -- cgit v1.2.3