package gov.nysenate.openleg.model.bill; import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.common.collect.ComparisonChain; import gov.nysenate.openleg.model.base.SessionYear; import gov.nysenate.openleg.model.base.Version; import gov.nysenate.openleg.model.entity.Chamber; import org.springframework.util.StringUtils; import java.io.Serializable; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * An immutable representation of the fields that are used to identify a particular bill. * This is mostly useful when other classes need to reference a particular bill but do not * necessarily need to store a complete object reference of the Bill or BillAmendment. */ public class BillId implements Serializable, Comparable<BillId> { private static final long serialVersionUID = 6494036869654732240L; public static String printNumberRegex = "([ASLREJKBC])([0-9]{1,5})([A-Z]?)"; public static Pattern printNumberPattern = Pattern.compile(printNumberRegex); public static Pattern billIdPattern = Pattern.compile("(?<printNo>" + printNumberRegex + ")-?(?<year>[0-9]{4})"); /** The default amendment version letter. */ public static final Version DEFAULT_VERSION = Version.DEFAULT; /** A number assigned to a bill when it's introduced in the Legislature. Each printNo begins with a * letter (A for Assembly, S for Senate) followed by 1 to 5 digits. This printNo is valid only for the * 2 year session period, after which it will be recycled. */ protected String basePrintNo; /** The session year of the bill. */ protected SessionYear session; /** The amendment version of the bill. */ protected Version version = DEFAULT_VERSION; /* --- Constructors --- */ public BillId(String printNo, int session) { this(printNo, new SessionYear(session)); } /** * Use this constructor when the version is not known or when the version may * be attached to the print no and needs to be parsed out. * * @param printNo String - e.g. 'S1234' or 'S1234A' * @param session int - e.g. 2013 */ public BillId(String printNo, SessionYear session) { printNo = normalizePrintNo(printNo); // Strip out the version from the print no if it exists. if (printNo.matches(".*[A-Z]$")) { this.version = Version.of(printNo.substring(printNo.length() - 1)); printNo = printNo.substring(0, printNo.length() - 1); } checkBasePrintHasNoVersion(printNo); this.basePrintNo = printNo; checkSessionYear(session); this.session = session; } /** * Performs strict checks on the basePrintNo when constructing BillId. If you have a bill id * as S02134A-2013, you should pass it in as ("S02134", 2013, "A"). If you do not have the * parsed representation of the printNo, use the {@link BillId(String, int)} constructor instead. * * @param basePrintNo String - e.g. S1234 -> GOOD, S1234A -> INVALID * @param session int * @param version String */ public BillId(String basePrintNo, int session, String version) { this(basePrintNo, new SessionYear(session), Version.of(version)); } /** * Performs strict checks on the basePrintNo when constructing BillId. If you have a bill id * as S02134A-2013, you should pass it in as ("S02134", 2013, "A"). If you do not have the * parsed representation of the printNo, use the {@link BillId(String, int)} constructor instead. * * @param baseBillId * @param version */ public BillId(BaseBillId baseBillId, Version version){ this(baseBillId.getBasePrintNo(),baseBillId.getSession(), version ); } /** * Performs strict checks on the basePrintNo when constructing BillId. If you have a bill id * as S02134A-2013, you should pass it in as ("S02134", 2013, "A"). If you do not have the * parsed representation of the printNo, use the {@link BillId(String, int)} constructor instead. * * @param basePrintNo String - e.g. S1234 -> GOOD, S1234A -> INVALID * @param session int * @param version String */ public BillId(String basePrintNo, SessionYear session, Version version) { basePrintNo = normalizePrintNo(basePrintNo); checkBasePrintHasNoVersion(basePrintNo); this.basePrintNo = basePrintNo; checkSessionYear(session); this.session = session; if (version == null) { version = DEFAULT_VERSION; } this.version = version; } /** --- Methods --- */ /** * Returns a BaseBillId instance from the given bill id which ensures that no amendment * version info will be stored. */ @JsonIgnore public static BaseBillId getBaseId(BillId billId) { return new BaseBillId(billId.basePrintNo, billId.session); } /** * Returns the full print no including amendment version, e.g. S1234A */ public String getPrintNo() { return this.basePrintNo + ((this.version != null) ? this.version : ""); } /** * Retrieves the type of bill based on the first letter designator. */ @JsonIgnore public BillType getBillType() { return BillType.valueOf(this.basePrintNo.substring(0, 1)); } /** * Indicates the chamber of the bill based on the letter designator. */ @JsonIgnore public Chamber getChamber() { return getBillType().getChamber(); } /** * Gets the number portion of the print number */ @JsonIgnore public int getNumber() { return Integer.parseInt(basePrintNo.replaceAll("[^\\d]", "")); } /** * Indicates if this bill is currently set to the base version. * * @param version The bill version * @return Returns true if the version will be represented as a base bill */ public static boolean isBaseVersion(Version version) { return version == null || version.equals(BillId.DEFAULT_VERSION); } /** * Creates a unique id for the bill with padding to resemble LBDC's representation. * * @return - The billId padded to 5 digits with zeros. */ @JsonIgnore public String getPaddedBillIdString() { return this.getPaddedPrintNumber() + "-" + this.getSession(); } /** * Returns the print number padded with 5 zeros, e.g S01234. This is how LBDC represents * print numbers in their SOBI files. * * @return - The print number padded to 5 digits with zeros. */ @JsonIgnore public String getPaddedPrintNumber() { Matcher billIdMatcher = printNumberPattern.matcher(this.getPrintNo()); if (billIdMatcher.find()) { return String.format("%s%05d%s", billIdMatcher.group(1), Integer.parseInt(billIdMatcher.group(2)), billIdMatcher.group(3)); } return ""; } /** --- Overrides --- */ /** * Given {basePrint:'S1234', version:'A', session:2013}, Output: 'S1234A-2013' * Note: Does not pad the string to a fixed length, use #getPaddedBillIdString() if padding is desired * * @return String representation of BillId. */ @Override public String toString() { return basePrintNo + ((version != null) ? version : "") + "-" + session; } @Override public boolean equals(Object o) { if (o == null) return false; if (!equalsBase(o)) return false; BillId oBillId = (BillId) o; return Objects.equals(this.version, oBillId.version); } @Override public int hashCode() { int result = hashCodeBase(); return (31 * result + Objects.hash(this.version)); } /** * An alternate equals comparison that ignores version so that two BillIds are equivalent if * their base BillIds match. */ public boolean equalsBase(Object o) { if (this == o) return true; if (o == null) return false; BillId oBillId = (BillId) o; return Objects.equals(this.session, oBillId.session) && Objects.equals(this.basePrintNo, oBillId.basePrintNo); } /** * Get hashcode without factoring in the version. Should use this when implementing hashcode method * for classes that contain a BillId where the version of the bill is not relevant. */ public int hashCodeBase() { return Objects.hash(this.basePrintNo, this.session); } @Override public int compareTo(BillId o) { return ComparisonChain.start() .compare(this.session, o.session) .compare(this.basePrintNo, o.basePrintNo) .compare(this.version, o.version) .result(); } /** --- Internal --- */ /** * Converts the printNo into a normalized form (no whitespace, all caps, all alphanumeric) and performs * basic checks to ensure that the printNo starts with the correct BillType designator. The normalized * printNo will be returned or an IllegalArgumentException thrown if printNo is invalid. * * @param printNo String - Input printNo * @return String - Normalized printNo */ private String normalizePrintNo(String printNo) { // Basic Null Check if (printNo == null || printNo.trim().isEmpty()) { throw new IllegalArgumentException("PrintNo when constructing BillId cannot be null/empty."); } // Remove all non-alphanumeric characters from the printNo. printNo = printNo.trim().toUpperCase().replaceAll("[^0-9A-Z]", ""); // Check that printNo starts with a valid bill type designator try { BillType.valueOf(String.valueOf(printNo.charAt(0))); } catch (IllegalArgumentException ex) { throw new IllegalArgumentException("PrintNo (" + printNo + ") must begin with a valid letter designator."); } // Trim leading 0's after the first character printNo = printNo.substring(0, 1) + StringUtils.trimLeadingCharacter(printNo.substring(1), '0'); return printNo; } /** * Check that base print no has no version character at the end. Throw an IllegalArgumentException if * there is. * * @param basePrintNo String * @throws java.lang.IllegalArgumentException - If basePrintNo has a character appended at the end */ private void checkBasePrintHasNoVersion(String basePrintNo) { if (basePrintNo.matches(".*[A-Z]$")) { throw new IllegalArgumentException("BasePrintNo cannot have a version appended to it. (" + basePrintNo + ")"); } } /** * Basic checks on the supplied SessionYear. * * @param session SessionYear */ private void checkSessionYear(SessionYear session) { if (session == null) { throw new IllegalArgumentException("Supplied SessionYear cannot be null"); } } /** --- Basic Getters/Setters --- */ public String getBasePrintNo() { return basePrintNo; } public SessionYear getSession() { return session; } public Version getVersion() { return version; } }